S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
import lombok.*;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@@ -19,9 +18,7 @@ public abstract class AbstractEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private <T> Set<T> emptyIfNull(Set<T> set) {
|
private <T> Set<T> emptyIfNull(Set<T> set) {
|
||||||
return set == null ?
|
return set == null ? new HashSet<>() : set;
|
||||||
new HashSet<>() :
|
|
||||||
set;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {
|
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
@Builder
|
@Builder
|
||||||
@@ -15,21 +14,27 @@ public class DanceGroup extends AbstractEntity {
|
|||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private DanceGroupType type;
|
private DanceGroupType type;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private int startAge;
|
private int startAge;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private int endAge;
|
private int endAge;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
|
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
private Coach coach;
|
private Coach coach;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
import lombok.*;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
@@ -12,14 +11,19 @@ import java.util.LinkedHashSet;
|
|||||||
public class DanceSchool extends AbstractEntity {
|
public class DanceSchool extends AbstractEntity {
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final java.util.UUID key = java.util.UUID.randomUUID();
|
private final java.util.UUID key = java.util.UUID.randomUUID();
|
||||||
|
|
||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
|
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Show> shows = new LinkedHashSet<>();
|
private final Set<Show> shows = new LinkedHashSet<>();
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Set<Coach> coaches = new LinkedHashSet<>();
|
private final Set<Coach> coaches = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
import lombok.*;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
@@ -13,6 +12,7 @@ public class Dancer extends AbstractEntity {
|
|||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private LocalDate birthDate;
|
private LocalDate birthDate;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
@Builder
|
@Builder
|
||||||
@@ -13,7 +12,9 @@ import java.time.LocalDate;
|
|||||||
public class DayPart extends AbstractEntity {
|
public class DayPart extends AbstractEntity {
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
private final DayPartType type;
|
private final DayPartType type;
|
||||||
|
|
||||||
private final LocalDate date;
|
private final LocalDate date;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private List<DanceGroup> danceGroups;
|
private List<DanceGroup> danceGroups;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package nl.agilecc.agiledancers.model;
|
package nl.agilecc.agiledancers.model;
|
||||||
|
|
||||||
import lombok.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import lombok.*;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -13,8 +12,10 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|||||||
public class Show extends AbstractEntity {
|
public class Show extends AbstractEntity {
|
||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Setter
|
@Setter
|
||||||
private Map<DanceGroupType, Integer> appearances;
|
private Map<DanceGroupType, Integer> appearances;
|
||||||
|
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
@Setter
|
@Setter
|
||||||
private List<DayPart> dayParts;
|
private List<DayPart> dayParts;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package nl.agilecc.agiledancers.model.specifications;
|
package nl.agilecc.agiledancers.model.specifications;
|
||||||
|
|
||||||
public enum DanceGroupStyle {
|
public enum DanceGroupStyle {
|
||||||
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE
|
HIPHOP,
|
||||||
|
JAZZ,
|
||||||
|
MODERN,
|
||||||
|
STREETDANCE,
|
||||||
|
CLASSIC,
|
||||||
|
BREAKDANCE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package nl.agilecc.agiledancers.model.specifications;
|
package nl.agilecc.agiledancers.model.specifications;
|
||||||
|
|
||||||
public enum DanceGroupType {
|
public enum DanceGroupType {
|
||||||
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
|
SHOW_CASE,
|
||||||
|
PRE_EDUCATION,
|
||||||
|
SELECTION,
|
||||||
|
REGULAR,
|
||||||
|
PRE_SCHOOLER
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.model.specifications;
|
package nl.agilecc.agiledancers.model.specifications;
|
||||||
|
|
||||||
public enum DayPartType {
|
public enum DayPartType {
|
||||||
MORNING, AFTERNOON, EVENING;
|
MORNING,
|
||||||
|
AFTERNOON,
|
||||||
|
EVENING;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.repository;
|
package nl.agilecc.agiledancers.repository;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.AbstractEntity;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import nl.agilecc.agiledancers.model.AbstractEntity;
|
||||||
|
|
||||||
public abstract class AbstractRepository<T extends AbstractEntity> {
|
public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||||
private long sequence = 0L;
|
private long sequence = 0L;
|
||||||
@@ -28,6 +27,7 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
|
|||||||
public T findById(long id) {
|
public T findById(long id) {
|
||||||
return entities.get(id);
|
return entities.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(long id) {
|
public void delete(long id) {
|
||||||
entities.remove(id);
|
entities.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.Coach;
|
import nl.agilecc.agiledancers.model.Coach;
|
||||||
|
|
||||||
public class CoachRepository extends AbstractRepository<Coach> {
|
public class CoachRepository extends AbstractRepository<Coach> {}
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
|
|
||||||
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {
|
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
|
|
||||||
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {
|
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
|
|
||||||
public class DancerRepository extends AbstractRepository<Dancer> {
|
public class DancerRepository extends AbstractRepository<Dancer> {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
|
||||||
public class DayPartRepository extends AbstractRepository<DayPart> {
|
public class DayPartRepository extends AbstractRepository<DayPart> {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
|||||||
|
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
|
|
||||||
public class ShowRepository extends AbstractRepository<Show> {
|
public class ShowRepository extends AbstractRepository<Show> {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -35,15 +33,26 @@ public class DanceGroupResource {
|
|||||||
|
|
||||||
@POST
|
@POST
|
||||||
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
|
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
|
||||||
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
|
var entity = service.createGroup(
|
||||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
|
||||||
.entity(GroupView.of(entity)).build();
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
|
.path(entity.getId().toString())
|
||||||
|
.build())
|
||||||
|
.entity(GroupView.of(entity))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
|
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
|
||||||
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
|
return GroupView.of(service.updateGroup(
|
||||||
|
schoolId,
|
||||||
|
id,
|
||||||
|
request.category(),
|
||||||
|
request.type(),
|
||||||
|
request.startAge(),
|
||||||
|
request.endAge(),
|
||||||
|
request.styles()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -33,8 +31,11 @@ public class DanceSchoolResource {
|
|||||||
@POST
|
@POST
|
||||||
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
|
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
|
||||||
var entity = service.createSchool(request.name());
|
var entity = service.createSchool(request.name());
|
||||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.entity(SchoolView.of(entity)).build();
|
.path(entity.getId().toString())
|
||||||
|
.build())
|
||||||
|
.entity(SchoolView.of(entity))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
@Path("/api/dance-schools/{schoolId}/dancers")
|
@Path("/api/dance-schools/{schoolId}/dancers")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -36,8 +34,11 @@ public class DancerResource {
|
|||||||
@POST
|
@POST
|
||||||
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
|
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
|
||||||
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
|
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
|
||||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.entity(DancerView.of(entity)).build();
|
.path(entity.getId().toString())
|
||||||
|
.build())
|
||||||
|
.entity(DancerView.of(entity))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||||
import jakarta.ws.rs.ext.Provider;
|
import jakarta.ws.rs.ext.Provider;
|
||||||
import nl.agilecc.agiledancers.service.ManagementException;
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
|
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
|
||||||
|
import nl.agilecc.agiledancers.service.ManagementException;
|
||||||
|
|
||||||
@Provider
|
@Provider
|
||||||
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
|
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
|
||||||
@@ -16,7 +16,9 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
|
|||||||
case INVALID_INPUT -> 400;
|
case INVALID_INPUT -> 400;
|
||||||
case CONFLICT -> 409;
|
case CONFLICT -> 409;
|
||||||
};
|
};
|
||||||
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
|
return Response.status(status)
|
||||||
.entity(new ErrorView(exception.getMessage())).build();
|
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||||
|
.entity(new ErrorView(exception.getMessage()))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
package nl.agilecc.agiledancers.rest;
|
package nl.agilecc.agiledancers.rest;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.service.model.*;
|
|
||||||
import com.fasterxml.jackson.databind.*;
|
|
||||||
import com.fasterxml.jackson.core.JsonParser;
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.databind.*;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import nl.agilecc.agiledancers.service.*;
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||||
|
import nl.agilecc.agiledancers.service.*;
|
||||||
|
import nl.agilecc.agiledancers.service.model.*;
|
||||||
|
|
||||||
@Path("/api/dance-schools")
|
@Path("/api/dance-schools")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public class SchoolSnapshotResource {
|
public class SchoolSnapshotResource {
|
||||||
@Inject SchoolManagementService service;
|
@Inject
|
||||||
@Inject ObjectMapper mapper;
|
SchoolManagementService service;
|
||||||
|
|
||||||
@GET @Path("/{id}/export")
|
@Inject
|
||||||
|
ObjectMapper mapper;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/export")
|
||||||
public Response export(@PathParam("id") long id) throws Exception {
|
public Response export(@PathParam("id") long id) throws Exception {
|
||||||
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
|
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
|
||||||
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
|
return Response.ok(json)
|
||||||
|
.header("Content-Disposition", "attachment; filename=school-" + id + ".json")
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
public ImportPreview preview(String json) { return service.preview(parse(json)); }
|
|
||||||
|
|
||||||
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
|
@POST
|
||||||
|
@Path("/import/preview")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public ImportPreview preview(String json) {
|
||||||
|
return service.preview(parse(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/import")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
|
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
|
||||||
return SchoolView.of(service.importSchool(parse(json), replaceId));
|
return SchoolView.of(service.importSchool(parse(json), replaceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private SchoolSnapshot parse(String json) {
|
private SchoolSnapshot parse(String json) {
|
||||||
try {
|
try {
|
||||||
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
SchoolSnapshot snapshot = mapper.copy()
|
||||||
|
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||||
@@ -38,7 +53,8 @@ public class SchoolSnapshotResource {
|
|||||||
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
|
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
|
||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import jakarta.validation.Valid;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||||
|
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
|
||||||
@Path("/api/dance-schools/{schoolId}/shows")
|
@Path("/api/dance-schools/{schoolId}/shows")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -42,8 +40,11 @@ public class ShowResource {
|
|||||||
@POST
|
@POST
|
||||||
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
|
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
|
||||||
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
||||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.entity(ShowView.of(entity)).build();
|
.path(entity.getId().toString())
|
||||||
|
.build())
|
||||||
|
.entity(ShowView.of(entity))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@@ -53,8 +54,13 @@ public class ShowResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static List<DayPart> parts(ShowRequest request) {
|
private static List<DayPart> parts(ShowRequest request) {
|
||||||
return request.dayParts().stream().map(p -> DayPart.builder()
|
return request.dayParts().stream()
|
||||||
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList();
|
.map(p -> DayPart.builder()
|
||||||
|
.date(p.date())
|
||||||
|
.type(p.type())
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -62,8 +68,8 @@ public class ShowResource {
|
|||||||
@Consumes(MediaType.WILDCARD)
|
@Consumes(MediaType.WILDCARD)
|
||||||
public SchedulingView schedule(@PathParam("id") long id) {
|
public SchedulingView schedule(@PathParam("id") long id) {
|
||||||
var result = scheduling.schedule(schoolId, id);
|
var result = scheduling.schedule(schoolId, id);
|
||||||
return new SchedulingView(result.optimal(), result.optimal()
|
return new SchedulingView(
|
||||||
? "Optimal balance" : "Valid result; optimal balance not proven");
|
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
import jakarta.validation.constraints.PastOrPresent;
|
import jakarta.validation.constraints.PastOrPresent;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
|
public record DancerRequest(
|
||||||
|
@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
|
|
||||||
public record DancerView(long id, String name, LocalDate birthDate) {
|
public record DancerView(long id, String name, LocalDate birthDate) {
|
||||||
public static DancerView of(Dancer dancer) {
|
public static DancerView of(Dancer dancer) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
import java.time.LocalDate;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDate;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}
|
|
||||||
|
public record DayPartRequest(
|
||||||
|
@NotNull LocalDate date, @NotNull DayPartType type) {}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
|
||||||
import java.util.List;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
|
|
||||||
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}
|
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package nl.agilecc.agiledancers.rest.apimodel;
|
|||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
|
public record GroupRequest(
|
||||||
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
|
@NotBlank String category,
|
||||||
|
@NotNull DanceGroupType type,
|
||||||
|
@NotNull @Min(0) Integer startAge,
|
||||||
|
@NotNull @Min(0) Integer endAge,
|
||||||
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
|
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
|
public record GroupView(
|
||||||
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
|
long id,
|
||||||
|
String category,
|
||||||
|
DanceGroupType type,
|
||||||
|
int startAge,
|
||||||
|
int endAge,
|
||||||
|
Set<DanceGroupStyle> styles,
|
||||||
|
List<Long> dancerIds) {
|
||||||
public static GroupView of(DanceGroup group) {
|
public static GroupView of(DanceGroup group) {
|
||||||
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
|
return new GroupView(
|
||||||
group.getEndAge(), Set.copyOf(group.getStyles()),
|
group.getId(),
|
||||||
|
group.getCategory(),
|
||||||
|
group.getType(),
|
||||||
|
group.getStartAge(),
|
||||||
|
group.getEndAge(),
|
||||||
|
Set.copyOf(group.getStyles()),
|
||||||
group.getDancers().stream().map(Dancer::getId).toList());
|
group.getDancers().stream().map(Dancer::getId).toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
public record SchedulingView(boolean optimal, String message) {}
|
public record SchedulingView(boolean optimal, String message) {}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record SchoolView(long id, String name, List<Long> dancerIds,
|
public record SchoolView(long id, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
|
||||||
List<Long> danceGroupIds, List<Long> showIds) {
|
|
||||||
public static SchoolView of(DanceSchool school) {
|
public static SchoolView of(DanceSchool school) {
|
||||||
return new SchoolView(school.getId(), school.getName(),
|
return new SchoolView(
|
||||||
|
school.getId(),
|
||||||
|
school.getName(),
|
||||||
school.getDancers().stream().map(Dancer::getId).toList(),
|
school.getDancers().stream().map(Dancer::getId).toList(),
|
||||||
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
|
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
|
||||||
school.getShows().stream().map(Show::getId).toList());
|
school.getShows().stream().map(Show::getId).toList());
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.*;
|
import jakarta.validation.constraints.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
|
||||||
|
public record ShowRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||||
Map<DanceGroupType, Integer> appearances) {}
|
Map<DanceGroupType, Integer> appearances) {}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
package nl.agilecc.agiledancers.rest.apimodel;
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
import nl.agilecc.agiledancers.model.*;
|
import nl.agilecc.agiledancers.model.*;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
import java.util.*;
|
|
||||||
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
||||||
public static ShowView of(Show show) {
|
public static ShowView of(Show show) {
|
||||||
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream()
|
return new ShowView(
|
||||||
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
|
show.getId(),
|
||||||
|
show.getName(),
|
||||||
|
show.getDayParts().stream()
|
||||||
|
.map(p -> new DayPartView(
|
||||||
|
p.getDate(),
|
||||||
|
p.getType(),
|
||||||
|
p.getDanceGroups().stream()
|
||||||
|
.map(DanceGroup::getId)
|
||||||
|
.toList()))
|
||||||
|
.toList(),
|
||||||
|
show.getAppearances());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
import nl.agilecc.agiledancers.model.Coach;
|
import nl.agilecc.agiledancers.model.Coach;
|
||||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
@@ -7,12 +8,17 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
|||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
|
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public class DanceGroupService {
|
public class DanceGroupService {
|
||||||
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
|
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
|
||||||
|
|
||||||
public DanceGroup add(String category, DanceGroupType type, int startAge, int endAge, Set<Dancer> dancers, Set<DanceGroupStyle> styles, Coach coach) {
|
public DanceGroup add(
|
||||||
|
String category,
|
||||||
|
DanceGroupType type,
|
||||||
|
int startAge,
|
||||||
|
int endAge,
|
||||||
|
Set<Dancer> dancers,
|
||||||
|
Set<DanceGroupStyle> styles,
|
||||||
|
Coach coach) {
|
||||||
DanceGroup danceGroup = DanceGroup.builder()
|
DanceGroup danceGroup = DanceGroup.builder()
|
||||||
.category(category)
|
.category(category)
|
||||||
.type(type)
|
.type(type)
|
||||||
@@ -20,7 +26,8 @@ public class DanceGroupService {
|
|||||||
.endAge(endAge)
|
.endAge(endAge)
|
||||||
.dancers(dancers)
|
.dancers(dancers)
|
||||||
.styles(styles)
|
.styles(styles)
|
||||||
.coach(coach).build();
|
.coach(coach)
|
||||||
|
.build();
|
||||||
|
|
||||||
return danceGroupRepository.add(danceGroup);
|
return danceGroupRepository.add(danceGroup);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.repository.DancerRepository;
|
import nl.agilecc.agiledancers.repository.DancerRepository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
public class DancerService {
|
public class DancerService {
|
||||||
private final DancerRepository dancerRepository = new DancerRepository();
|
private final DancerRepository dancerRepository = new DancerRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }
|
public enum ManagementExceptionKind {
|
||||||
|
NOT_FOUND,
|
||||||
|
INVALID_INPUT,
|
||||||
|
CONFLICT
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class SchedulingService {
|
public class SchedulingService {
|
||||||
@Inject SchoolManagementService schools;
|
@Inject
|
||||||
@Inject SchedulingEngine engine;
|
SchoolManagementService schools;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SchedulingEngine engine;
|
||||||
|
|
||||||
public SchedulingResult schedule(long schoolId, long showId) {
|
public SchedulingResult schedule(long schoolId, long showId) {
|
||||||
var input = schools.beginScheduling(schoolId, showId);
|
var input = schools.beginScheduling(schoolId, showId);
|
||||||
try {
|
try {
|
||||||
var result = engine.solve(input);
|
var result = engine.solve(input);
|
||||||
schools.finishScheduling(schoolId, input, result);
|
schools.finishScheduling(schoolId, input, result);
|
||||||
return result;
|
return result;
|
||||||
} catch (ManagementException e) { throw e; }
|
} catch (ManagementException e) {
|
||||||
catch (RuntimeException | LinkageError e) {
|
throw e;
|
||||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
|
} catch (RuntimeException | LinkageError e) {
|
||||||
} finally { schools.endScheduling(showId); }
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
|
"Solver failed. Existing assignments were preserved. Please run scheduling again.");
|
||||||
|
} finally {
|
||||||
|
schools.endScheduling(showId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
|
||||||
|
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.*;
|
||||||
|
import nl.agilecc.agiledancers.model.*;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.service.model.*;
|
|
||||||
import jakarta.inject.Singleton;
|
|
||||||
import nl.agilecc.agiledancers.model.*;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.*;
|
import nl.agilecc.agiledancers.model.specifications.*;
|
||||||
import nl.agilecc.agiledancers.repository.*;
|
import nl.agilecc.agiledancers.repository.*;
|
||||||
import java.time.LocalDate;
|
import nl.agilecc.agiledancers.service.model.*;
|
||||||
import java.util.*;
|
|
||||||
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
|
|
||||||
|
|
||||||
/** Owns the in-memory school aggregates; all writes go through this shared service. */
|
/** Owns the in-memory school aggregates; all writes go through this shared service. */
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -22,31 +23,52 @@ public class SchoolManagementService {
|
|||||||
|
|
||||||
private final Map<Long, Long> revisions = new HashMap<>();
|
private final Map<Long, Long> revisions = new HashMap<>();
|
||||||
private final Set<Long> running = new HashSet<>();
|
private final Set<Long> running = new HashSet<>();
|
||||||
private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); }
|
|
||||||
|
private void touch(Show show) {
|
||||||
|
revisions.merge(show.getId(), 1L, Long::sum);
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
|
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
|
||||||
Show show = show(schoolId, showId);
|
Show show = show(schoolId, showId);
|
||||||
if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
|
if (running.contains(showId))
|
||||||
|
throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
|
||||||
List<DanceGroup> current = groups(schoolId);
|
List<DanceGroup> current = groups(schoolId);
|
||||||
if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
|
if (current.isEmpty() || show.getDayParts().isEmpty())
|
||||||
|
throw new ManagementException(
|
||||||
|
INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
|
||||||
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
||||||
running.add(showId);
|
running.add(showId);
|
||||||
return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(),
|
return new SchedulingInput(
|
||||||
current.stream().map(g -> new GroupRequirement(g.getId(), show.getAppearances().get(g.getType()))).toList());
|
showId,
|
||||||
|
revisions.getOrDefault(showId, 0L),
|
||||||
|
show.getDayParts().size(),
|
||||||
|
current.stream()
|
||||||
|
.map(g -> new GroupRequirement(
|
||||||
|
g.getId(), show.getAppearances().get(g.getType())))
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
|
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
|
||||||
Show show;
|
Show show;
|
||||||
try { show = show(schoolId, input.showId()); }
|
try {
|
||||||
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
|
show = show(schoolId, input.showId());
|
||||||
|
} catch (ManagementException e) {
|
||||||
|
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||||
|
}
|
||||||
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
|
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
|
||||||
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||||
// Validate the complete solver output before mutating any assignments.
|
// Validate the complete solver output before mutating any assignments.
|
||||||
if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts())
|
if (result == null
|
||||||
throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
|
|| result.assignments() == null
|
||||||
|
|| result.assignments().size() != input.dayParts())
|
||||||
|
throw new ManagementException(
|
||||||
|
INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
|
||||||
Map<Long, DanceGroup> current = new HashMap<>();
|
Map<Long, DanceGroup> current = new HashMap<>();
|
||||||
groups(schoolId).forEach(g -> current.put(g.getId(), g));
|
groups(schoolId).forEach(g -> current.put(g.getId(), g));
|
||||||
for (var part : result.assignments()) {
|
for (var part : result.assignments()) {
|
||||||
if (part == null || new HashSet<>(part).size() != part.size() || !current.keySet().containsAll(part))
|
if (part == null
|
||||||
|
|| new HashSet<>(part).size() != part.size()
|
||||||
|
|| !current.keySet().containsAll(part))
|
||||||
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
|
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
|
||||||
}
|
}
|
||||||
for (GroupRequirement group : input.groups()) {
|
for (GroupRequirement group : input.groups()) {
|
||||||
@@ -57,12 +79,20 @@ public class SchoolManagementService {
|
|||||||
List<DayPart> replacement = new ArrayList<>();
|
List<DayPart> replacement = new ArrayList<>();
|
||||||
for (int p = 0; p < input.dayParts(); p++) {
|
for (int p = 0; p < input.dayParts(); p++) {
|
||||||
DayPart old = show.getDayParts().get(p);
|
DayPart old = show.getDayParts().get(p);
|
||||||
replacement.add(DayPart.builder().date(old.getDate()).type(old.getType())
|
replacement.add(DayPart.builder()
|
||||||
.danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build());
|
.date(old.getDate())
|
||||||
|
.type(old.getType())
|
||||||
|
.danceGroups(result.assignments().get(p).stream()
|
||||||
|
.map(current::get)
|
||||||
|
.toList())
|
||||||
|
.build());
|
||||||
}
|
}
|
||||||
show.setDayParts(List.copyOf(replacement));
|
show.setDayParts(List.copyOf(replacement));
|
||||||
}
|
}
|
||||||
public synchronized void endScheduling(long showId) { running.remove(showId); }
|
|
||||||
|
public synchronized void endScheduling(long showId) {
|
||||||
|
running.remove(showId);
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized List<DanceSchool> schools() {
|
public synchronized List<DanceSchool> schools() {
|
||||||
return schools.findAll();
|
return schools.findAll();
|
||||||
@@ -100,7 +130,8 @@ public class SchoolManagementService {
|
|||||||
|
|
||||||
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
|
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
|
||||||
DanceSchool school = school(schoolId);
|
DanceSchool school = school(schoolId);
|
||||||
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
Dancer dancer =
|
||||||
|
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
||||||
school.addDancer(dancer);
|
school.addDancer(dancer);
|
||||||
return dancer;
|
return dancer;
|
||||||
}
|
}
|
||||||
@@ -128,19 +159,35 @@ public class SchoolManagementService {
|
|||||||
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
|
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
|
public synchronized DanceGroup createGroup(
|
||||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
long schoolId,
|
||||||
|
String category,
|
||||||
|
DanceGroupType type,
|
||||||
|
int startAge,
|
||||||
|
int endAge,
|
||||||
|
Set<DanceGroupStyle> styles) {
|
||||||
DanceSchool school = school(schoolId);
|
DanceSchool school = school(schoolId);
|
||||||
validateAgeRange(startAge, endAge);
|
validateAgeRange(startAge, endAge);
|
||||||
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
|
DanceGroup group = groups.add(DanceGroup.builder()
|
||||||
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
|
.category(category)
|
||||||
|
.type(type)
|
||||||
|
.startAge(startAge)
|
||||||
|
.endAge(endAge)
|
||||||
|
.styles(new LinkedHashSet<>(styles))
|
||||||
|
.build());
|
||||||
school.addDanceGroup(group);
|
school.addDanceGroup(group);
|
||||||
school.getShows().forEach(this::touch);
|
school.getShows().forEach(this::touch);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
|
public synchronized DanceGroup updateGroup(
|
||||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
long schoolId,
|
||||||
|
long groupId,
|
||||||
|
String category,
|
||||||
|
DanceGroupType type,
|
||||||
|
int startAge,
|
||||||
|
int endAge,
|
||||||
|
Set<DanceGroupStyle> styles) {
|
||||||
DanceGroup group = group(schoolId, groupId);
|
DanceGroup group = group(schoolId, groupId);
|
||||||
validateAgeRange(startAge, endAge);
|
validateAgeRange(startAge, endAge);
|
||||||
group.setCategory(category);
|
group.setCategory(category);
|
||||||
@@ -178,23 +225,39 @@ public class SchoolManagementService {
|
|||||||
return member(school(schoolId).getShows(), showId, "Show");
|
return member(school(schoolId).getShows(), showId, "Show");
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
public synchronized Show createShow(
|
||||||
|
long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||||
DanceSchool school = school(schoolId);
|
DanceSchool school = school(schoolId);
|
||||||
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
|
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
|
||||||
ShowConfiguration.validate(parts, counts);
|
ShowConfiguration.validate(parts, counts);
|
||||||
Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
|
Show show = shows.add(Show.builder()
|
||||||
|
.name(name)
|
||||||
|
.dayParts(List.copyOf(parts))
|
||||||
|
.appearances(Map.copyOf(counts))
|
||||||
|
.build());
|
||||||
school.addShow(show);
|
school.addShow(show);
|
||||||
return show;
|
return show;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized Show updateShow(long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
public synchronized Show updateShow(
|
||||||
|
long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||||
Show show = show(schoolId, showId);
|
Show show = show(schoolId, showId);
|
||||||
if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
if (counts == null)
|
||||||
|
counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
||||||
ShowConfiguration.validate(parts, counts);
|
ShowConfiguration.validate(parts, counts);
|
||||||
boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
boolean changed =
|
||||||
List<DayPart> retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType())
|
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||||
.danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
List<DayPart> retained = parts.stream()
|
||||||
.findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList();
|
.map(p -> DayPart.builder()
|
||||||
|
.date(p.getDate())
|
||||||
|
.type(p.getType())
|
||||||
|
.danceGroups(show.getDayParts().stream()
|
||||||
|
.filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||||
|
.findFirst()
|
||||||
|
.map(DayPart::getDanceGroups)
|
||||||
|
.orElse(List.of()))
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
show.setName(name);
|
show.setName(name);
|
||||||
show.setDayParts(retained);
|
show.setDayParts(retained);
|
||||||
show.setAppearances(Map.copyOf(counts));
|
show.setAppearances(Map.copyOf(counts));
|
||||||
@@ -212,16 +275,23 @@ public class SchoolManagementService {
|
|||||||
shows.delete(showId);
|
shows.delete(showId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public synchronized SchoolSnapshot exportSchool(long id) {
|
public synchronized SchoolSnapshot exportSchool(long id) {
|
||||||
return SchoolSnapshot.of(school(id));
|
return SchoolSnapshot.of(school(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
|
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
|
||||||
snapshot.restore();
|
snapshot.restore();
|
||||||
DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null);
|
DanceSchool existing = schools.findAll().stream()
|
||||||
return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(),
|
.filter(s -> s.getKey().equals(snapshot.key()))
|
||||||
existing == null ? null : existing.getId(), existing == null ? null : existing.getName());
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
return new ImportPreview(
|
||||||
|
snapshot.name(),
|
||||||
|
snapshot.dancers().size(),
|
||||||
|
snapshot.groups().size(),
|
||||||
|
snapshot.shows().size(),
|
||||||
|
existing == null ? null : existing.getId(),
|
||||||
|
existing == null ? null : existing.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
|
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
|
||||||
@@ -244,21 +314,49 @@ public class SchoolManagementService {
|
|||||||
List<DanceGroup> rebuiltGroups = new ArrayList<>();
|
List<DanceGroup> rebuiltGroups = new ArrayList<>();
|
||||||
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
|
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
|
||||||
for (DanceGroup g : importedGroups) {
|
for (DanceGroup g : importedGroups) {
|
||||||
DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType())
|
DanceGroup rebuilt = DanceGroup.builder()
|
||||||
.startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles()))
|
.category(g.getCategory())
|
||||||
.coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build();
|
.type(g.getType())
|
||||||
rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt);
|
.startAge(g.getStartAge())
|
||||||
|
.endAge(g.getEndAge())
|
||||||
|
.styles(new LinkedHashSet<>(g.getStyles()))
|
||||||
|
.coach(g.getCoach())
|
||||||
|
.dancers(new LinkedHashSet<>(memberships.get(g)))
|
||||||
|
.build();
|
||||||
|
rebuilt.setId(g.getId());
|
||||||
|
rebuiltGroups.add(rebuilt);
|
||||||
|
replacements.put(g, rebuilt);
|
||||||
}
|
}
|
||||||
List<Show> rebuiltShows = new ArrayList<>();
|
List<Show> rebuiltShows = new ArrayList<>();
|
||||||
for (Show show : importedShows) {
|
for (Show show : importedShows) {
|
||||||
Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
|
Show rebuilt = Show.builder()
|
||||||
.map(p -> DayPart.builder().date(p.getDate()).type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
|
.name(show.getName())
|
||||||
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
|
.appearances(show.getAppearances())
|
||||||
|
.dayParts(show.getDayParts().stream()
|
||||||
|
.map(p -> DayPart.builder()
|
||||||
|
.date(p.getDate())
|
||||||
|
.type(p.getType())
|
||||||
|
.danceGroups(p.getDanceGroups().stream()
|
||||||
|
.map(replacements::get)
|
||||||
|
.toList())
|
||||||
|
.build())
|
||||||
|
.toList())
|
||||||
|
.build();
|
||||||
|
rebuilt.setId(shows.nextId());
|
||||||
|
rebuiltShows.add(rebuilt);
|
||||||
|
}
|
||||||
|
DanceSchool imported = DanceSchool.builder()
|
||||||
|
.key(staged.getKey())
|
||||||
|
.name(staged.getName())
|
||||||
|
.dancers(new LinkedHashSet<>(importedDancers))
|
||||||
|
.danceGroups(new LinkedHashSet<>(rebuiltGroups))
|
||||||
|
.shows(new LinkedHashSet<>(rebuiltShows))
|
||||||
|
.coaches(new LinkedHashSet<>(staged.getCoaches()))
|
||||||
|
.build();
|
||||||
|
if (replaceId != null) {
|
||||||
|
deleteSchool(replaceId);
|
||||||
|
imported.setId(replaceId);
|
||||||
}
|
}
|
||||||
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
|
|
||||||
.dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups))
|
|
||||||
.shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build();
|
|
||||||
if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); }
|
|
||||||
importedDancers.forEach(dancers::add);
|
importedDancers.forEach(dancers::add);
|
||||||
rebuiltGroups.forEach(groups::add);
|
rebuiltGroups.forEach(groups::add);
|
||||||
rebuiltShows.forEach(shows::add);
|
rebuiltShows.forEach(shows::add);
|
||||||
@@ -272,7 +370,9 @@ public class SchoolManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
|
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
|
||||||
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
|
return entities.stream()
|
||||||
|
.filter(entity -> entity.getId() == id)
|
||||||
|
.findFirst()
|
||||||
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
|
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public final class ShowConfiguration {
|
public final class ShowConfiguration {
|
||||||
private ShowConfiguration() {}
|
private ShowConfiguration() {}
|
||||||
|
|
||||||
public static Map<DanceGroupType, Integer> defaults(int n) {
|
public static Map<DanceGroupType, Integer> defaults(int n) {
|
||||||
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||||
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) {
|
if (n > 0)
|
||||||
|
for (var type : DanceGroupType.values())
|
||||||
|
counts.put(
|
||||||
|
type,
|
||||||
|
switch (type) {
|
||||||
case PRE_SCHOOLER -> 1;
|
case PRE_SCHOOLER -> 1;
|
||||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
case SHOW_CASE, PRE_EDUCATION -> n;
|
||||||
default -> Math.min(2, n);
|
default -> Math.min(2, n);
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||||
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|
if (parts == null
|
||||||
|| parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size())
|
|| parts.size() > 6
|
||||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations.");
|
|| parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|
||||||
|
|| parts.stream()
|
||||||
|
.map(p -> p.getDate() + ":" + p.getType())
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
!= parts.size())
|
||||||
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
|
"Configure zero to six dayparts with unique date/type combinations.");
|
||||||
if (counts == null) invalidCounts();
|
if (counts == null) invalidCounts();
|
||||||
if (parts.isEmpty() && counts.isEmpty()) return;
|
if (parts.isEmpty() && counts.isEmpty()) return;
|
||||||
if (counts.size() != DanceGroupType.values().length
|
if (counts.size() != DanceGroupType.values().length
|
||||||
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|
|| Arrays.stream(DanceGroupType.values())
|
||||||
|
.anyMatch(t -> counts.get(t) == null
|
||||||
|
|| counts.get(t) < 1
|
||||||
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
|
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void invalidCounts() {
|
private static void invalidCounts() {
|
||||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT,
|
throw new ManagementException(
|
||||||
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
|
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are"
|
||||||
|
+ " retained when dayparts are removed.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
|
public record Group(
|
||||||
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
|
String id,
|
||||||
|
String category,
|
||||||
|
DanceGroupType type,
|
||||||
|
Integer startAge,
|
||||||
|
Integer endAge,
|
||||||
|
List<DanceGroupStyle> styles,
|
||||||
|
List<String> dancerIds,
|
||||||
|
String coachId) {}
|
||||||
|
|||||||
@@ -1,31 +1,62 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
|
||||||
import nl.agilecc.agiledancers.service.ManagementException;
|
|
||||||
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
|
||||||
import nl.agilecc.agiledancers.model.*;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.*;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import nl.agilecc.agiledancers.model.*;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.*;
|
||||||
|
import nl.agilecc.agiledancers.service.ManagementException;
|
||||||
|
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||||
|
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
||||||
|
|
||||||
/** Version 2 file format. IDs are local to the file, never server identifiers. */
|
/** Version 2 file format. IDs are local to the file, never server identifiers. */
|
||||||
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
|
public record SchoolSnapshot(
|
||||||
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
|
Integer formatVersion,
|
||||||
|
UUID key,
|
||||||
|
String name,
|
||||||
|
List<Person> dancers,
|
||||||
|
List<Trainer> coaches,
|
||||||
|
List<Group> groups,
|
||||||
|
List<Performance> shows) {
|
||||||
public static SchoolSnapshot of(DanceSchool s) {
|
public static SchoolSnapshot of(DanceSchool s) {
|
||||||
Map<Dancer, String> dancers = ids(s.getDancers());
|
Map<Dancer, String> dancers = ids(s.getDancers());
|
||||||
Map<Coach, String> coaches = ids(s.getCoaches());
|
Map<Coach, String> coaches = ids(s.getCoaches());
|
||||||
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||||
Map<Show, String> shows = ids(s.getShows());
|
Map<Show, String> shows = ids(s.getShows());
|
||||||
return new SchoolSnapshot(2, s.getKey(), s.getName(),
|
return new SchoolSnapshot(
|
||||||
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
|
2,
|
||||||
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
|
s.getKey(),
|
||||||
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
|
s.getName(),
|
||||||
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
|
s.getDancers().stream()
|
||||||
|
.map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
||||||
|
.toList(),
|
||||||
|
s.getCoaches().stream()
|
||||||
|
.map(c -> new Trainer(coaches.get(c), c.getName()))
|
||||||
|
.toList(),
|
||||||
|
s.getDanceGroups().stream()
|
||||||
|
.map(g -> new Group(
|
||||||
|
groups.get(g),
|
||||||
|
g.getCategory(),
|
||||||
|
g.getType(),
|
||||||
|
g.getStartAge(),
|
||||||
|
g.getEndAge(),
|
||||||
|
List.copyOf(g.getStyles()),
|
||||||
g.getDancers().stream().map(dancers::get).toList(),
|
g.getDancers().stream().map(dancers::get).toList(),
|
||||||
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
|
g.getCoach() == null ? null : coaches.get(g.getCoach())))
|
||||||
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(),
|
.toList(),
|
||||||
show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
|
s.getShows().stream()
|
||||||
p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
|
.map(show -> new Performance(
|
||||||
|
shows.get(show),
|
||||||
|
show.getName(),
|
||||||
|
show.getDayParts().stream()
|
||||||
|
.map(p -> new Part(
|
||||||
|
p.getDate(),
|
||||||
|
p.getType(),
|
||||||
|
p.getDanceGroups().stream()
|
||||||
|
.map(groups::get)
|
||||||
|
.toList()))
|
||||||
|
.toList(),
|
||||||
|
show.getAppearances()))
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static <T> Map<T, String> ids(Collection<T> values) {
|
private static <T> Map<T, String> ids(Collection<T> values) {
|
||||||
@@ -36,57 +67,129 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
|
|||||||
|
|
||||||
public DanceSchool restore() {
|
public DanceSchool restore() {
|
||||||
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||||
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
|
check(key != null, "key ontbreekt");
|
||||||
|
text(name, "schoolnaam");
|
||||||
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||||
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
|
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
|
||||||
for (Person d : list(dancers, "dancers")) {
|
for (Person d : list(dancers, "dancers")) {
|
||||||
check(d != null, "Lege danser"); text(d.name(), "dansernaam");
|
check(d != null, "Lege danser");
|
||||||
check(d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name());
|
text(d.name(), "dansernaam");
|
||||||
Dancer dancer = Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
|
check(
|
||||||
|
d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()),
|
||||||
|
"Ongeldige geboortedatum voor " + d.name());
|
||||||
|
Dancer dancer =
|
||||||
|
Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
|
||||||
// Assign temporary distinct IDs before inserting entities into sets.
|
// Assign temporary distinct IDs before inserting entities into sets.
|
||||||
dancer.setId((long) dancerMap.size());
|
dancer.setId((long) dancerMap.size());
|
||||||
put(dancerMap, d.id(), dancer); school.addDancer(dancer);
|
put(dancerMap, d.id(), dancer);
|
||||||
|
school.addDancer(dancer);
|
||||||
}
|
}
|
||||||
Map<String, Coach> coachMap = new LinkedHashMap<>();
|
Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||||
for (Trainer c : list(coaches, "coaches")) {
|
for (Trainer c : list(coaches, "coaches")) {
|
||||||
check(c != null, "Lege coach"); text(c.name(), "coachnaam");
|
check(c != null, "Lege coach");
|
||||||
Coach coach = Coach.builder().name(c.name()).build(); coach.setId((long) coachMap.size());
|
text(c.name(), "coachnaam");
|
||||||
put(coachMap, c.id(), coach); school.addCoach(coach);
|
Coach coach = Coach.builder().name(c.name()).build();
|
||||||
|
coach.setId((long) coachMap.size());
|
||||||
|
put(coachMap, c.id(), coach);
|
||||||
|
school.addCoach(coach);
|
||||||
}
|
}
|
||||||
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
|
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
|
||||||
for (Group g : list(groups, "groups")) {
|
for (Group g : list(groups, "groups")) {
|
||||||
check(g != null, "Lege groep"); text(g.category(), "groepsnaam");
|
check(g != null, "Lege groep");
|
||||||
check(g.type() != null && g.startAge() != null && g.endAge() != null && g.startAge() >= 0 && g.endAge() >= g.startAge(),
|
text(g.category(), "groepsnaam");
|
||||||
|
check(
|
||||||
|
g.type() != null
|
||||||
|
&& g.startAge() != null
|
||||||
|
&& g.endAge() != null
|
||||||
|
&& g.startAge() >= 0
|
||||||
|
&& g.endAge() >= g.startAge(),
|
||||||
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
|
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
|
||||||
DanceGroup group = DanceGroup.builder().category(g.category()).type(g.type()).startAge(g.startAge()).endAge(g.endAge())
|
DanceGroup group = DanceGroup.builder()
|
||||||
.styles(new LinkedHashSet<>(list(g.styles(), "styles"))).coach(g.coachId() == null ? null : ref(coachMap, g.coachId(), "groep " + g.category() + " verwijst naar een onbekende coach" )).build();
|
.category(g.category())
|
||||||
|
.type(g.type())
|
||||||
|
.startAge(g.startAge())
|
||||||
|
.endAge(g.endAge())
|
||||||
|
.styles(new LinkedHashSet<>(list(g.styles(), "styles")))
|
||||||
|
.coach(
|
||||||
|
g.coachId() == null
|
||||||
|
? null
|
||||||
|
: ref(
|
||||||
|
coachMap,
|
||||||
|
g.coachId(),
|
||||||
|
"groep " + g.category() + " verwijst naar een onbekende coach"))
|
||||||
|
.build();
|
||||||
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
|
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
|
||||||
group.setId((long) groupMap.size());
|
group.setId((long) groupMap.size());
|
||||||
for (String id : unique(g.dancerIds(), "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
|
for (String id : unique(g.dancerIds(), "dancerIds"))
|
||||||
put(groupMap, g.id(), group); school.addDanceGroup(group);
|
group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
|
||||||
|
put(groupMap, g.id(), group);
|
||||||
|
school.addDanceGroup(group);
|
||||||
}
|
}
|
||||||
Set<String> showIds = new HashSet<>();
|
Set<String> showIds = new HashSet<>();
|
||||||
for (Performance show : list(shows, "shows")) {
|
for (Performance show : list(shows, "shows")) {
|
||||||
check(show != null, "Lege show"); text(show.id(), "show-id");
|
check(show != null, "Lege show");
|
||||||
check(showIds.add(show.id()), "Dubbele show-id " + show.id()); text(show.name(), "shownaam");
|
text(show.id(), "show-id");
|
||||||
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
|
check(showIds.add(show.id()), "Dubbele show-id " + show.id());
|
||||||
|
text(show.name(), "shownaam");
|
||||||
|
List<DayPart> parts = new ArrayList<>();
|
||||||
|
Set<String> types = new HashSet<>();
|
||||||
for (Part part : list(show.dayParts(), "dayParts")) {
|
for (Part part : list(show.dayParts(), "dayParts")) {
|
||||||
check(part != null && part.type() != null && part.date() != null && types.add(part.date() + ":" + part.type()), "Ongeldig of dubbel dagdeel voor " + show.name());
|
check(
|
||||||
parts.add(DayPart.builder().date(part.date()).type(part.type()).danceGroups(unique(part.groupIds(), "groupIds").stream()
|
part != null
|
||||||
.map(id -> ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")).toList()).build());
|
&& part.type() != null
|
||||||
|
&& part.date() != null
|
||||||
|
&& types.add(part.date() + ":" + part.type()),
|
||||||
|
"Ongeldig of dubbel dagdeel voor " + show.name());
|
||||||
|
parts.add(DayPart.builder()
|
||||||
|
.date(part.date())
|
||||||
|
.type(part.type())
|
||||||
|
.danceGroups(unique(part.groupIds(), "groupIds").stream()
|
||||||
|
.map(id ->
|
||||||
|
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
||||||
|
.toList())
|
||||||
|
.build());
|
||||||
}
|
}
|
||||||
ShowConfiguration.validate(parts, show.appearances());
|
ShowConfiguration.validate(parts, show.appearances());
|
||||||
Show entity = Show.builder().name(show.name()).dayParts(parts).appearances(Map.copyOf(show.appearances())).build();
|
Show entity = Show.builder()
|
||||||
entity.setId((long) showIds.size()); school.addShow(entity);
|
.name(show.name())
|
||||||
|
.dayParts(parts)
|
||||||
|
.appearances(Map.copyOf(show.appearances()))
|
||||||
|
.build();
|
||||||
|
entity.setId((long) showIds.size());
|
||||||
|
school.addShow(entity);
|
||||||
}
|
}
|
||||||
return school;
|
return school;
|
||||||
}
|
}
|
||||||
private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); }
|
|
||||||
private static <T> List<T> list(List<T> value, String field) { check(value != null, field + " ontbreekt"); return value; }
|
private static void text(String value, String field) {
|
||||||
|
check(value != null && !value.isBlank(), field + " ontbreekt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> list(List<T> value, String field) {
|
||||||
|
check(value != null, field + " ontbreekt");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<String> unique(List<String> value, String field) {
|
private static List<String> unique(List<String> value, String field) {
|
||||||
list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value;
|
list(value, field);
|
||||||
|
check(
|
||||||
|
value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(),
|
||||||
|
"Ongeldige of dubbele verwijzing in " + field);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> void put(Map<String, T> map, String id, T value) {
|
||||||
|
text(id, "id");
|
||||||
|
check(map.putIfAbsent(id, value) == null, "Dubbele id " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> T ref(Map<String, T> map, String id, String error) {
|
||||||
|
T value = map.get(id);
|
||||||
|
check(value != null, error);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void check(boolean valid, String message) {
|
||||||
|
if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message);
|
||||||
}
|
}
|
||||||
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
|
|
||||||
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
|
|
||||||
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package nl.agilecc.agiledancers.solver;
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
|
||||||
import com.google.ortools.Loader;
|
import com.google.ortools.Loader;
|
||||||
import com.google.ortools.sat.*;
|
import com.google.ortools.sat.*;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import nl.agilecc.agiledancers.service.*;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
|
import nl.agilecc.agiledancers.service.*;
|
||||||
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||||
@Override
|
@Override
|
||||||
@@ -39,15 +40,21 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
||||||
CpSolverStatus status = solver.solve(model);
|
CpSolverStatus status = solver.solve(model);
|
||||||
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
|
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
|
||||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, switch (status) {
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
|
switch (status) {
|
||||||
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
||||||
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved.";
|
case UNKNOWN ->
|
||||||
|
"The 30-minute limit was reached without a valid result. Existing assignments were"
|
||||||
|
+ " preserved.";
|
||||||
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
|
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
|
||||||
});
|
});
|
||||||
List<List<Long>> parts = new ArrayList<>();
|
List<List<Long>> parts = new ArrayList<>();
|
||||||
for (int p = 0; p < n; p++) {
|
for (int p = 0; p < n; p++) {
|
||||||
List<Long> ids = new ArrayList<>();
|
List<Long> ids = new ArrayList<>();
|
||||||
for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groups().get(g).groupId());
|
for (int g = 0; g < groups; g++)
|
||||||
|
if (solver.booleanValue(assigned[g][p]))
|
||||||
|
ids.add(input.groups().get(g).groupId());
|
||||||
parts.add(List.copyOf(ids));
|
parts.add(List.copyOf(ids));
|
||||||
}
|
}
|
||||||
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
|
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import com.tngtech.archunit.core.importer.ImportOption;
|
|
||||||
import com.tngtech.archunit.core.domain.JavaModifier;
|
|
||||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
|
||||||
import com.tngtech.archunit.junit.ArchTest;
|
|
||||||
import com.tngtech.archunit.lang.ArchRule;
|
|
||||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||||
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
|
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
|
||||||
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
|
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaModifier;
|
||||||
|
import com.tngtech.archunit.core.importer.ImportOption;
|
||||||
|
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||||
|
import com.tngtech.archunit.junit.ArchTest;
|
||||||
|
import com.tngtech.archunit.lang.ArchRule;
|
||||||
|
|
||||||
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
|
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||||
class ArchitectureTest {
|
class ArchitectureTest {
|
||||||
private static final String ROOT = "nl.agilecc.agiledancers";
|
private static final String ROOT = "nl.agilecc.agiledancers";
|
||||||
@@ -18,85 +19,128 @@ class ArchitectureTest {
|
|||||||
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
|
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule handwritten_types_are_top_level = classes()
|
static final ArchRule handwritten_types_are_top_level = classes()
|
||||||
.that().areNotAnnotatedWith("lombok.Generated")
|
.that()
|
||||||
.and().doNotHaveModifier(JavaModifier.SYNTHETIC)
|
.areNotAnnotatedWith("lombok.Generated")
|
||||||
.should().beTopLevelClasses();
|
.and()
|
||||||
|
.doNotHaveModifier(JavaModifier.SYNTHETIC)
|
||||||
|
.should()
|
||||||
|
.beTopLevelClasses();
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule service_records_reside_in_service_model = classes()
|
static final ArchRule service_records_reside_in_service_model = classes()
|
||||||
.that().resideInAPackage(ROOT + ".service..")
|
.that()
|
||||||
.and().areRecords()
|
.resideInAPackage(ROOT + ".service..")
|
||||||
.should().resideInAPackage(ROOT + ".service.model");
|
.and()
|
||||||
|
.areRecords()
|
||||||
|
.should()
|
||||||
|
.resideInAPackage(ROOT + ".service.model");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule service_interfaces_reside_in_service_port = classes()
|
static final ArchRule service_interfaces_reside_in_service_port = classes()
|
||||||
.that().resideInAPackage(ROOT + ".service..")
|
.that()
|
||||||
.and().areInterfaces()
|
.resideInAPackage(ROOT + ".service..")
|
||||||
.should().resideInAPackage(ROOT + ".service.port");
|
.and()
|
||||||
|
.areInterfaces()
|
||||||
|
.should()
|
||||||
|
.resideInAPackage(ROOT + ".service.port");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule scheduling_port_uses_domain_types = classes()
|
static final ArchRule scheduling_port_uses_domain_types = classes()
|
||||||
.that().resideInAPackage(ROOT + ".service.port..")
|
.that()
|
||||||
.should().onlyDependOnClassesThat()
|
.resideInAPackage(ROOT + ".service.port..")
|
||||||
|
.should()
|
||||||
|
.onlyDependOnClassesThat()
|
||||||
.resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java..");
|
.resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java..");
|
||||||
|
|
||||||
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
|
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule api_models_reside_in_the_api_model_package = classes()
|
static final ArchRule api_models_reside_in_the_api_model_package = classes()
|
||||||
.that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
|
.that()
|
||||||
.should().resideInAPackage(API_MODEL);
|
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
|
||||||
|
.should()
|
||||||
|
.resideInAPackage(API_MODEL);
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
|
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
|
||||||
.that().resideInAPackage(ROOT + ".rest..")
|
.that()
|
||||||
.and().areRecords()
|
.resideInAPackage(ROOT + ".rest..")
|
||||||
.should().resideInAPackage(API_MODEL);
|
.and()
|
||||||
|
.areRecords()
|
||||||
|
.should()
|
||||||
|
.resideInAPackage(API_MODEL);
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule api_models_are_public_top_level_types = classes()
|
static final ArchRule api_models_are_public_top_level_types = classes()
|
||||||
.that().resideInAPackage(API_MODEL + "..")
|
.that()
|
||||||
.should().bePublic()
|
.resideInAPackage(API_MODEL + "..")
|
||||||
.andShould().beTopLevelClasses()
|
.should()
|
||||||
.andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
|
.bePublic()
|
||||||
|
.andShould()
|
||||||
|
.beTopLevelClasses()
|
||||||
|
.andShould()
|
||||||
|
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
|
||||||
|
|
||||||
// The existing services depend on concrete repositories. This rule preserves that
|
// The existing services depend on concrete repositories. This rule preserves that
|
||||||
// arrangement until repository ports and dependency injection are introduced.
|
// arrangement until repository ports and dependency injection are introduced.
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule layer_dependencies = layeredArchitecture()
|
static final ArchRule layer_dependencies = layeredArchitecture()
|
||||||
.consideringOnlyDependenciesInLayers()
|
.consideringOnlyDependenciesInLayers()
|
||||||
.layer("Model").definedBy(ROOT + ".model..")
|
.layer("Model")
|
||||||
.layer("Services").definedBy(ROOT + ".service..")
|
.definedBy(ROOT + ".model..")
|
||||||
.layer("Repositories").definedBy(ROOT + ".repository..")
|
.layer("Services")
|
||||||
.optionalLayer("REST").definedBy(ROOT + ".rest..")
|
.definedBy(ROOT + ".service..")
|
||||||
.optionalLayer("Solver").definedBy(ROOT + ".solver..")
|
.layer("Repositories")
|
||||||
.whereLayer("Model").mayNotAccessAnyLayer()
|
.definedBy(ROOT + ".repository..")
|
||||||
.whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories")
|
.optionalLayer("REST")
|
||||||
.whereLayer("Repositories").mayOnlyAccessLayers("Model")
|
.definedBy(ROOT + ".rest..")
|
||||||
.whereLayer("REST").mayOnlyAccessLayers("Services", "Model")
|
.optionalLayer("Solver")
|
||||||
.whereLayer("Solver").mayOnlyAccessLayers("Services", "Model");
|
.definedBy(ROOT + ".solver..")
|
||||||
|
.whereLayer("Model")
|
||||||
|
.mayNotAccessAnyLayer()
|
||||||
|
.whereLayer("Services")
|
||||||
|
.mayOnlyAccessLayers("Model", "Repositories")
|
||||||
|
.whereLayer("Repositories")
|
||||||
|
.mayOnlyAccessLayers("Model")
|
||||||
|
.whereLayer("REST")
|
||||||
|
.mayOnlyAccessLayers("Services", "Model")
|
||||||
|
.whereLayer("Solver")
|
||||||
|
.mayOnlyAccessLayers("Services", "Model");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule production_classes_have_an_architectural_home = classes()
|
static final ArchRule production_classes_have_an_architectural_home = classes()
|
||||||
.should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..",
|
.should()
|
||||||
ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver..");
|
.resideInAnyPackage(
|
||||||
|
ROOT + ".model..",
|
||||||
|
ROOT + ".service..",
|
||||||
|
ROOT + ".repository..",
|
||||||
|
ROOT + ".rest..",
|
||||||
|
ROOT + ".solver..");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule model_is_independent_of_frameworks = classes()
|
static final ArchRule model_is_independent_of_frameworks = classes()
|
||||||
.that().resideInAPackage(ROOT + ".model..")
|
.that()
|
||||||
.should().onlyDependOnClassesThat()
|
.resideInAPackage(ROOT + ".model..")
|
||||||
|
.should()
|
||||||
|
.onlyDependOnClassesThat()
|
||||||
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
|
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
|
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
|
||||||
.that().resideOutsideOfPackage(ROOT + ".solver..")
|
.that()
|
||||||
.should().dependOnClassesThat().resideInAPackage("com.google.ortools..");
|
.resideOutsideOfPackage(ROOT + ".solver..")
|
||||||
|
.should()
|
||||||
|
.dependOnClassesThat()
|
||||||
|
.resideInAPackage("com.google.ortools..");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
|
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
|
||||||
.that().resideOutsideOfPackage(ROOT + ".rest..")
|
.that()
|
||||||
.should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
|
.resideOutsideOfPackage(ROOT + ".rest..")
|
||||||
|
.should()
|
||||||
|
.dependOnClassesThat()
|
||||||
|
.resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
|
||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
static final ArchRule packages_are_free_of_cycles = slices()
|
static final ArchRule packages_are_free_of_cycles =
|
||||||
.matching(ROOT + ".(*)..").should().beFreeOfCycles();
|
slices().matching(ROOT + ".(*)..").should().beFreeOfCycles();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static io.restassured.RestAssured.given;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@QuarkusTest
|
@QuarkusTest
|
||||||
class HealthTest {
|
class HealthTest {
|
||||||
@Test
|
@Test
|
||||||
void applicationIsReady() {
|
void applicationIsReady() {
|
||||||
given()
|
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
|
||||||
.when().get("/q/health/ready")
|
|
||||||
.then().statusCode(200)
|
|
||||||
.body("status", equalTo("UP"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,80 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import nl.agilecc.agiledancers.service.model.*;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
|
||||||
import nl.agilecc.agiledancers.model.*;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.*;
|
|
||||||
import nl.agilecc.agiledancers.service.*;
|
|
||||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import nl.agilecc.agiledancers.model.*;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.*;
|
||||||
|
import nl.agilecc.agiledancers.service.*;
|
||||||
|
import nl.agilecc.agiledancers.service.model.*;
|
||||||
|
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class SchedulingTest {
|
class SchedulingTest {
|
||||||
private final SchoolManagementService service = new SchoolManagementService();
|
private final SchoolManagementService service = new SchoolManagementService();
|
||||||
private final long school = service.createSchool("Studio").getId();
|
private final long school = service.createSchool("Studio").getId();
|
||||||
|
|
||||||
private List<DayPart> parts(int n) {
|
private List<DayPart> parts(int n) {
|
||||||
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder()
|
return java.util.stream.IntStream.range(0, n)
|
||||||
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList();
|
.mapToObj(i -> DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1).plusDays(i))
|
||||||
|
.type(DayPartType.AFTERNOON)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private DanceGroup group(DanceGroupType type) {
|
private DanceGroup group(DanceGroupType type) {
|
||||||
return service.createGroup(school, "Group", type, 0, 99, Set.of());
|
return service.createGroup(school, "Group", type, 0, 99, Set.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void run(Show show) {
|
private void run(Show show) {
|
||||||
var input = service.beginScheduling(school, show.getId());
|
var input = service.beginScheduling(school, show.getId());
|
||||||
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); }
|
try {
|
||||||
finally { service.endScheduling(show.getId()); }
|
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
|
||||||
|
} finally {
|
||||||
|
service.endScheduling(show.getId());
|
||||||
}
|
}
|
||||||
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
|
||||||
group(DanceGroupType.PRE_SCHOOLER);
|
group(DanceGroupType.PRE_SCHOOLER);
|
||||||
var show = service.createShow(school, "Show", parts(6), null);
|
var show = service.createShow(school, "Show", parts(6), null);
|
||||||
run(show);
|
run(show);
|
||||||
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
|
var loads = show.getDayParts().stream()
|
||||||
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax());
|
.mapToInt(p -> p.getDanceGroups().size())
|
||||||
|
.summaryStatistics();
|
||||||
|
assertEquals(1, loads.getSum());
|
||||||
|
assertEquals(0, loads.getMin());
|
||||||
|
assertEquals(1, loads.getMax());
|
||||||
for (var type : DanceGroupType.values()) group(type);
|
for (var type : DanceGroupType.values()) group(type);
|
||||||
run(show);
|
run(show);
|
||||||
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(),
|
for (var group : service.groups(school))
|
||||||
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count());
|
assertEquals(
|
||||||
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
|
show.getAppearances().get(group.getType()).longValue(),
|
||||||
|
show.getDayParts().stream()
|
||||||
|
.filter(p -> p.getDanceGroups().contains(group))
|
||||||
|
.count());
|
||||||
|
loads = show.getDayParts().stream()
|
||||||
|
.mapToInt(p -> p.getDanceGroups().size())
|
||||||
|
.summaryStatistics();
|
||||||
assertTrue(loads.getMax() - loads.getMin() <= 1);
|
assertTrue(loads.getMax() - loads.getMin() <= 1);
|
||||||
var snapshot = service.exportSchool(school);
|
var snapshot = service.exportSchool(school);
|
||||||
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
|
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
|
||||||
}
|
}
|
||||||
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
|
|
||||||
|
@Test
|
||||||
|
void defaultsInitialConfigurationAndPreservationOnEdits() {
|
||||||
var show = service.createShow(school, "Show", List.of(), null);
|
var show = service.createShow(school, "Show", List.of(), null);
|
||||||
assertTrue(show.getAppearances().isEmpty());
|
assertTrue(show.getAppearances().isEmpty());
|
||||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
||||||
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||||
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||||
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
var counts = new EnumMap<>(show.getAppearances());
|
||||||
|
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||||
service.updateShow(school, show.getId(), "Show", parts(4), counts);
|
service.updateShow(school, show.getId(), "Show", parts(4), counts);
|
||||||
assertEquals(counts, show.getAppearances());
|
assertEquals(counts, show.getAppearances());
|
||||||
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
|
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
|
||||||
@@ -58,19 +84,25 @@ class SchedulingTest {
|
|||||||
service.updateShow(school, show.getId(), "Show", parts(4), null);
|
service.updateShow(school, show.getId(), "Show", parts(4), null);
|
||||||
assertEquals(counts, show.getAppearances());
|
assertEquals(counts, show.getAppearances());
|
||||||
}
|
}
|
||||||
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
|
||||||
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
|
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
|
||||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
|
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
|
||||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
|
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
|
||||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
|
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
|
||||||
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3);
|
var counts = ShowConfiguration.defaults(2);
|
||||||
|
counts.put(DanceGroupType.REGULAR, 3);
|
||||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
||||||
counts.put(DanceGroupType.REGULAR, 0);
|
counts.put(DanceGroupType.REGULAR, 0);
|
||||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
||||||
}
|
}
|
||||||
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
|
|
||||||
|
@Test
|
||||||
|
void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
|
||||||
var group = group(DanceGroupType.REGULAR);
|
var group = group(DanceGroupType.REGULAR);
|
||||||
var show = service.createShow(school, "Show", parts(2), null); run(show);
|
var show = service.createShow(school, "Show", parts(2), null);
|
||||||
|
run(show);
|
||||||
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
|
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
|
||||||
var input = service.beginScheduling(school, show.getId());
|
var input = service.beginScheduling(school, show.getId());
|
||||||
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
||||||
@@ -79,18 +111,25 @@ class SchedulingTest {
|
|||||||
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
|
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
|
||||||
group(DanceGroupType.PRE_SCHOOLER);
|
group(DanceGroupType.PRE_SCHOOLER);
|
||||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
||||||
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
assertEquals(
|
||||||
|
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
||||||
service.endScheduling(show.getId());
|
service.endScheduling(show.getId());
|
||||||
var fresh = service.beginScheduling(school, show.getId());
|
var fresh = service.beginScheduling(school, show.getId());
|
||||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh,
|
assertThrows(
|
||||||
new SchedulingResult(false, List.of(List.of(), List.of()))));
|
ManagementException.class,
|
||||||
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
() -> service.finishScheduling(
|
||||||
|
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
|
||||||
|
assertEquals(
|
||||||
|
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
||||||
// Feasible results are accepted without requiring proof of optimality.
|
// Feasible results are accepted without requiring proof of optimality.
|
||||||
var valid = new OrToolsSchedulingEngine().solve(fresh);
|
var valid = new OrToolsSchedulingEngine().solve(fresh);
|
||||||
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
|
assertDoesNotThrow(
|
||||||
|
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
|
||||||
service.endScheduling(show.getId());
|
service.endScheduling(show.getId());
|
||||||
}
|
}
|
||||||
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
|
|
||||||
|
@Test
|
||||||
|
void staleDaypartCountChangesAndDeletedShowsAreRejected() {
|
||||||
group(DanceGroupType.REGULAR);
|
group(DanceGroupType.REGULAR);
|
||||||
var show = service.createShow(school, "Show", parts(2), null);
|
var show = service.createShow(school, "Show", parts(2), null);
|
||||||
var input = service.beginScheduling(school, show.getId());
|
var input = service.beginScheduling(school, show.getId());
|
||||||
@@ -101,7 +140,9 @@ class SchedulingTest {
|
|||||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
||||||
service.endScheduling(show.getId());
|
service.endScheduling(show.getId());
|
||||||
}
|
}
|
||||||
@Test void needsGroupsAndDayparts() {
|
|
||||||
|
@Test
|
||||||
|
void needsGroupsAndDayparts() {
|
||||||
var show = service.createShow(school, "Show", parts(1), null);
|
var show = service.createShow(school, "Show", parts(1), null);
|
||||||
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
||||||
group(DanceGroupType.REGULAR);
|
group(DanceGroupType.REGULAR);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
import io.restassured.response.ValidatableResponse;
|
import io.restassured.response.ValidatableResponse;
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import static io.restassured.RestAssured.given;
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.hamcrest.Matchers.*;
|
|
||||||
|
|
||||||
@QuarkusTest
|
@QuarkusTest
|
||||||
class SchoolManagementResourceTest {
|
class SchoolManagementResourceTest {
|
||||||
private static final String SCHOOLS = "/api/dance-schools";
|
private static final String SCHOOLS = "/api/dance-schools";
|
||||||
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
|
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
|
||||||
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
|
private static final Map<String, Object> GROUP = Map.of(
|
||||||
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
|
"category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"});
|
||||||
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
|
private static final Map<String, Object> SHOW =
|
||||||
|
Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void managesSchoolAndItsResources() {
|
void managesSchoolAndItsResources() {
|
||||||
@@ -27,37 +29,64 @@ class SchoolManagementResourceTest {
|
|||||||
long dancerId = create(school + "/dancers", DANCER);
|
long dancerId = create(school + "/dancers", DANCER);
|
||||||
String dancer = school + "/dancers/" + dancerId;
|
String dancer = school + "/dancers/" + dancerId;
|
||||||
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
|
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
|
||||||
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
|
.statusCode(200)
|
||||||
|
.body("birthDate", equalTo("2011-06-13"));
|
||||||
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
|
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
|
||||||
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
|
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
|
||||||
|
|
||||||
long groupId = create(school + "/dance-groups", GROUP);
|
long groupId = create(school + "/dance-groups", GROUP);
|
||||||
String group = school + "/dance-groups/" + groupId;
|
String group = school + "/dance-groups/" + groupId;
|
||||||
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
|
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
|
||||||
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
|
put(
|
||||||
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
|
group,
|
||||||
.statusCode(200).body("category", equalTo("Selection"))
|
Map.of(
|
||||||
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
|
"category",
|
||||||
|
"Selection",
|
||||||
|
"type",
|
||||||
|
"SELECTION",
|
||||||
|
"startAge",
|
||||||
|
10,
|
||||||
|
"endAge",
|
||||||
|
20,
|
||||||
|
"styles",
|
||||||
|
new String[] {"JAZZ", "MODERN"}))
|
||||||
|
.statusCode(200)
|
||||||
|
.body("category", equalTo("Selection"))
|
||||||
|
.body("dancerIds", contains((int) dancerId))
|
||||||
|
.body("styles", containsInAnyOrder("JAZZ", "MODERN"));
|
||||||
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
|
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
|
||||||
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
|
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
|
||||||
|
|
||||||
long showId = create(school + "/shows", SHOW);
|
long showId = create(school + "/shows", SHOW);
|
||||||
String show = school + "/shows/" + showId;
|
String show = school + "/shows/" + showId;
|
||||||
given().get(show).then().statusCode(200)
|
given().get(show)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
.body("dayParts.type", contains("AFTERNOON"))
|
.body("dayParts.type", contains("AFTERNOON"))
|
||||||
.body("dayParts.danceGroupIds", everyItem(empty()));
|
.body("dayParts.danceGroupIds", everyItem(empty()));
|
||||||
put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
|
put(show, Map.of("name", "Summer show", "dayParts", new Object[] {
|
||||||
.statusCode(200).body("dayParts.date", contains("2027-07-01"));
|
Map.of("date", "2027-07-01", "type", "EVENING")
|
||||||
|
}))
|
||||||
|
.statusCode(200)
|
||||||
|
.body("dayParts.date", contains("2027-07-01"));
|
||||||
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
|
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
|
||||||
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
|
given().get(school)
|
||||||
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("dancerIds", contains((int) dancerId))
|
||||||
|
.body("danceGroupIds", contains((int) groupId))
|
||||||
|
.body("showIds", contains((int) showId));
|
||||||
|
|
||||||
for (String path : new String[] {dancer, group, show}) {
|
for (String path : new String[] {dancer, group, show}) {
|
||||||
given().delete(path).then().statusCode(204);
|
given().delete(path).then().statusCode(204);
|
||||||
given().get(path).then().statusCode(404);
|
given().get(path).then().statusCode(404);
|
||||||
given().delete(path).then().statusCode(404);
|
given().delete(path).then().statusCode(404);
|
||||||
}
|
}
|
||||||
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
|
given().get(school)
|
||||||
|
.then()
|
||||||
|
.body("dancerIds", empty())
|
||||||
|
.body("danceGroupIds", empty())
|
||||||
|
.body("showIds", empty());
|
||||||
} finally {
|
} finally {
|
||||||
given().delete(school).then().statusCode(204);
|
given().delete(school).then().statusCode(204);
|
||||||
}
|
}
|
||||||
@@ -105,7 +134,9 @@ class SchoolManagementResourceTest {
|
|||||||
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
|
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
|
||||||
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
|
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
|
||||||
put(second + "/shows/" + show, SHOW).statusCode(404);
|
put(second + "/shows/" + show, SHOW).statusCode(404);
|
||||||
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
|
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer)
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
|
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
|
||||||
given().delete(first).then().statusCode(204);
|
given().delete(first).then().statusCode(204);
|
||||||
given().get(first + "/dancers").then().statusCode(404);
|
given().get(first + "/dancers").then().statusCode(404);
|
||||||
@@ -121,30 +152,66 @@ class SchoolManagementResourceTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsInvalidInputWithoutChangingStoredData() {
|
void rejectsInvalidInputWithoutChangingStoredData() {
|
||||||
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
|
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
|
||||||
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
|
given().contentType("application/json")
|
||||||
|
.body("null")
|
||||||
|
.post(SCHOOLS)
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
|
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
|
||||||
try {
|
try {
|
||||||
put(school, Map.of("name", " ")).statusCode(400);
|
put(school, Map.of("name", " ")).statusCode(400);
|
||||||
given().get(school).then().body("name", equalTo("Validation school"));
|
given().get(school).then().body("name", equalTo("Validation school"));
|
||||||
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
|
for (Object body : new Object[] {
|
||||||
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
|
Map.of("name", "Sam"),
|
||||||
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
|
Map.of("name", "Sam", "birthDate", "2999-01-01"),
|
||||||
|
Map.of("name", "Sam", "birthDate", "not-a-date")
|
||||||
|
}) {
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(body)
|
||||||
|
.post(school + "/dancers")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
}
|
}
|
||||||
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
|
for (String body : new String[] {
|
||||||
|
"{}",
|
||||||
|
"{\"category\":\"Teens\",\"type\":\"INVALID\"}",
|
||||||
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
|
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
|
||||||
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
|
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"
|
||||||
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
|
}) {
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(body)
|
||||||
|
.post(school + "/dance-groups")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
}
|
}
|
||||||
long group = create(school + "/dance-groups", GROUP);
|
long group = create(school + "/dance-groups", GROUP);
|
||||||
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
|
put(
|
||||||
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
|
school + "/dance-groups/" + group,
|
||||||
|
Map.of(
|
||||||
|
"category",
|
||||||
|
"Invalid",
|
||||||
|
"type",
|
||||||
|
"REGULAR",
|
||||||
|
"startAge",
|
||||||
|
20,
|
||||||
|
"endAge",
|
||||||
|
10,
|
||||||
|
"styles",
|
||||||
|
new String[] {}))
|
||||||
|
.statusCode(400);
|
||||||
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
|
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
|
||||||
given().contentType("application/json").body(Map.of("name", "Show"))
|
given().contentType("application/json")
|
||||||
.post(school + "/shows").then().statusCode(400);
|
.body(Map.of("name", "Show"))
|
||||||
|
.post(school + "/shows")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
given().get(school + "/dancers").then().body("$", empty());
|
given().get(school + "/dancers").then().body("$", empty());
|
||||||
given().get(school + "/shows").then().body("$", empty());
|
given().get(school + "/shows").then().body("$", empty());
|
||||||
given().contentType("application/json").body(DANCER)
|
given().contentType("application/json")
|
||||||
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
|
.body(DANCER)
|
||||||
|
.post(SCHOOLS + "/9223372036854775807/dancers")
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
} finally {
|
} finally {
|
||||||
given().delete(school).then().statusCode(204);
|
given().delete(school).then().statusCode(204);
|
||||||
}
|
}
|
||||||
@@ -162,21 +229,43 @@ class SchoolManagementResourceTest {
|
|||||||
String before = given().get(show).asString();
|
String before = given().get(show).asString();
|
||||||
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
||||||
for (String count : new String[] {"0", "2", "1.5", "null"}) {
|
for (String count : new String[] {"0", "2", "1.5", "null"}) {
|
||||||
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}";
|
String body =
|
||||||
|
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
|
||||||
|
+ counts.formatted(count) + "}";
|
||||||
put(show, body).statusCode(400);
|
put(show, body).statusCode(400);
|
||||||
given().get(show).then().body(equalTo(before));
|
given().get(show).then().body(equalTo(before));
|
||||||
}
|
}
|
||||||
String exported = given().get(school + "/export").then().statusCode(200).extract().asString();
|
String exported = given().get(school + "/export")
|
||||||
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
.then()
|
||||||
.then().statusCode(200);
|
.statusCode(200)
|
||||||
given().get(school + "/export").then().body("$", equalTo(io.restassured.path.json.JsonPath.from(exported).getMap("$")));
|
.extract()
|
||||||
} finally { given().delete(school); }
|
.asString();
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(exported)
|
||||||
|
.post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
given().get(school + "/export")
|
||||||
|
.then()
|
||||||
|
.body(
|
||||||
|
"$",
|
||||||
|
equalTo(io.restassured.path.json.JsonPath.from(exported)
|
||||||
|
.getMap("$")));
|
||||||
|
} finally {
|
||||||
|
given().delete(school);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long create(String path, Object body) {
|
private static long create(String path, Object body) {
|
||||||
return given().contentType("application/json").body(body).post(path).then()
|
return given().contentType("application/json")
|
||||||
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
|
.body(body)
|
||||||
.extract().jsonPath().getLong("id");
|
.post(path)
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.header("Location", matchesPattern(".*/[0-9]+$"))
|
||||||
|
.extract()
|
||||||
|
.jsonPath()
|
||||||
|
.getLong("id");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ValidatableResponse put(String path, Object body) {
|
private static ValidatableResponse put(String path, Object body) {
|
||||||
|
|||||||
@@ -1,32 +1,68 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import static io.restassured.RestAssured.given;
|
|
||||||
import static org.hamcrest.Matchers.*;
|
|
||||||
|
|
||||||
@QuarkusTest
|
@QuarkusTest
|
||||||
class SchoolSnapshotResourceTest {
|
class SchoolSnapshotResourceTest {
|
||||||
private static final String BASE = "/api/dance-schools";
|
private static final String BASE = "/api/dance-schools";
|
||||||
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
|
|
||||||
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
|
@Test
|
||||||
.then().statusCode(201).extract().jsonPath().getLong("id");
|
void downloadPreviewRestoreAndRejectInvalidFiles() {
|
||||||
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
|
long id = given().contentType("application/json")
|
||||||
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString();
|
.body("{\"name\":\"JSON school\"}")
|
||||||
|
.post(BASE)
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.jsonPath()
|
||||||
|
.getLong("id");
|
||||||
|
String json = given().get(BASE + "/" + id + "/export")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.header("Content-Disposition", containsString(".json"))
|
||||||
|
.body("formatVersion", equalTo(2))
|
||||||
|
.extract()
|
||||||
|
.asString();
|
||||||
try {
|
try {
|
||||||
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
|
given().contentType("application/json")
|
||||||
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
|
.body(json)
|
||||||
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
.post(BASE + "/import/preview")
|
||||||
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
|
.then()
|
||||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
|
.statusCode(200)
|
||||||
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
|
.body("existingId", equalTo((int) id))
|
||||||
.then().statusCode(400).body("message", not(emptyOrNullString()));
|
.body("dancers", equalTo(0));
|
||||||
|
for (String bad : new String[] {
|
||||||
|
"{",
|
||||||
|
"null",
|
||||||
|
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
||||||
|
json.replace("\"name\" :", "\"unknown\" :"),
|
||||||
|
json + " {}",
|
||||||
|
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
||||||
|
}) {
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(bad)
|
||||||
|
.post(BASE + "/import?replaceId=" + id)
|
||||||
|
.then()
|
||||||
|
.statusCode(400)
|
||||||
|
.body("message", not(emptyOrNullString()));
|
||||||
}
|
}
|
||||||
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
||||||
given().delete(BASE + "/" + id).then().statusCode(204);
|
given().delete(BASE + "/" + id).then().statusCode(204);
|
||||||
id = given().contentType("application/json").body(json).post(BASE + "/import")
|
id = given().contentType("application/json")
|
||||||
.then().statusCode(200).extract().jsonPath().getLong("id");
|
.body(json)
|
||||||
|
.post(BASE + "/import")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.jsonPath()
|
||||||
|
.getLong("id");
|
||||||
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
||||||
} finally { given().delete(BASE + "/" + id); }
|
} finally {
|
||||||
|
given().delete(BASE + "/" + id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
package nl.agilecc.agiledancers;
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.service.model.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import nl.agilecc.agiledancers.service.*;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.*;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import nl.agilecc.agiledancers.model.specifications.*;
|
||||||
|
import nl.agilecc.agiledancers.service.*;
|
||||||
|
import nl.agilecc.agiledancers.service.model.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class SchoolSnapshotTest {
|
class SchoolSnapshotTest {
|
||||||
private SchoolSnapshot complete() {
|
private SchoolSnapshot complete() {
|
||||||
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))),
|
return new SchoolSnapshot(
|
||||||
|
2,
|
||||||
|
UUID.randomUUID(),
|
||||||
|
"Studio",
|
||||||
|
List.of(new Person("a", "Sam", LocalDate.of(2010, 1, 1))),
|
||||||
List.of(new Trainer("c", "Alex")),
|
List.of(new Trainer("c", "Alex")),
|
||||||
List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
|
List.of(
|
||||||
|
new Group(
|
||||||
|
"g",
|
||||||
|
"Hiphop",
|
||||||
|
DanceGroupType.REGULAR,
|
||||||
|
0,
|
||||||
|
99,
|
||||||
|
List.of(DanceGroupStyle.HIPHOP),
|
||||||
|
List.of("a"),
|
||||||
|
"c"),
|
||||||
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
|
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
|
||||||
List.of(new Performance("s", "Show",
|
List.of(new Performance(
|
||||||
List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
|
"s",
|
||||||
|
"Show",
|
||||||
|
List.of(new Part(LocalDate.of(2027, 1, 1), DayPartType.MORNING, List.of("g", "h"))),
|
||||||
|
ShowConfiguration.defaults(1))));
|
||||||
}
|
}
|
||||||
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
|
|
||||||
|
@Test
|
||||||
|
void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
|
||||||
var snapshot = complete();
|
var snapshot = complete();
|
||||||
var service = new SchoolManagementService();
|
var service = new SchoolManagementService();
|
||||||
var existing = service.createSchool("Other");
|
var existing = service.createSchool("Other");
|
||||||
@@ -25,7 +44,8 @@ class SchoolSnapshotTest {
|
|||||||
var imported = service.importSchool(snapshot, null);
|
var imported = service.importSchool(snapshot, null);
|
||||||
assertEquals(2, imported.getDanceGroups().size());
|
assertEquals(2, imported.getDanceGroups().size());
|
||||||
var dancer = imported.getDancers().iterator().next();
|
var dancer = imported.getDancers().iterator().next();
|
||||||
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
|
imported.getDanceGroups()
|
||||||
|
.forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
|
||||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
|
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
|
||||||
var restarted = new SchoolManagementService();
|
var restarted = new SchoolManagementService();
|
||||||
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
|
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
|
||||||
@@ -36,12 +56,24 @@ class SchoolSnapshotTest {
|
|||||||
assertEquals(1, existing.getDancers().size());
|
assertEquals(1, existing.getDancers().size());
|
||||||
var group = imported.getDanceGroups().iterator().next();
|
var group = imported.getDanceGroups().iterator().next();
|
||||||
service.deleteGroup(imported.getId(), group.getId());
|
service.deleteGroup(imported.getId(), group.getId());
|
||||||
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
|
assertEquals(
|
||||||
|
1,
|
||||||
|
imported.getShows()
|
||||||
|
.iterator()
|
||||||
|
.next()
|
||||||
|
.getDayParts()
|
||||||
|
.getFirst()
|
||||||
|
.getDanceGroups()
|
||||||
|
.size());
|
||||||
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
|
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
|
||||||
}
|
}
|
||||||
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
|
|
||||||
var service = new SchoolManagementService(); var snapshot = complete();
|
@Test
|
||||||
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
|
void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
|
||||||
|
var service = new SchoolManagementService();
|
||||||
|
var snapshot = complete();
|
||||||
|
assertNull(service.preview(snapshot).existingId());
|
||||||
|
assertTrue(service.schools().isEmpty());
|
||||||
var original = service.importSchool(snapshot, null);
|
var original = service.importSchool(snapshot, null);
|
||||||
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
|
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
|
||||||
var other = service.createSchool(snapshot.name());
|
var other = service.createSchool(snapshot.name());
|
||||||
@@ -52,20 +84,31 @@ class SchoolSnapshotTest {
|
|||||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
|
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
|
||||||
assertSame(other, service.school(other.getId()));
|
assertSame(other, service.school(other.getId()));
|
||||||
service.deleteSchool(replaced.getId());
|
service.deleteSchool(replaced.getId());
|
||||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
|
assertEquals(
|
||||||
|
SchoolSnapshot.of(snapshot.restore()),
|
||||||
|
service.exportSchool(service.importSchool(snapshot, null).getId()));
|
||||||
}
|
}
|
||||||
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
|
|
||||||
var service = new SchoolManagementService(); var valid = complete();
|
@Test
|
||||||
|
void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
|
||||||
|
var service = new SchoolManagementService();
|
||||||
|
var valid = complete();
|
||||||
var original = service.importSchool(valid, null);
|
var original = service.importSchool(valid, null);
|
||||||
var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
|
var bad = new SchoolSnapshot(
|
||||||
|
2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
|
||||||
assertThrows(ManagementException.class, () -> service.preview(bad));
|
assertThrows(ManagementException.class, () -> service.preview(bad));
|
||||||
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
|
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
|
||||||
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
|
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
|
||||||
}
|
}
|
||||||
@Test void emptySchoolRoundTrips() {
|
|
||||||
|
@Test
|
||||||
|
void emptySchoolRoundTrips() {
|
||||||
var service = new SchoolManagementService();
|
var service = new SchoolManagementService();
|
||||||
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
|
var empty = service.createSchool("Empty");
|
||||||
|
var snapshot = service.exportSchool(empty.getId());
|
||||||
service.deleteSchool(empty.getId());
|
service.deleteSchool(empty.getId());
|
||||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
|
assertEquals(
|
||||||
|
SchoolSnapshot.of(snapshot.restore()),
|
||||||
|
service.exportSchool(service.importSchool(snapshot, null).getId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
package nl.agilecc.agiledancers.service;
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import nl.agilecc.agiledancers.model.*;
|
|
||||||
import nl.agilecc.agiledancers.model.specifications.*;
|
|
||||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import nl.agilecc.agiledancers.model.*;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.*;
|
||||||
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
|
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class SchedulingServiceTest {
|
class SchedulingServiceTest {
|
||||||
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
@Test
|
||||||
|
void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||||
var schools = new SchoolManagementService();
|
var schools = new SchoolManagementService();
|
||||||
long school = schools.createSchool("School").getId();
|
long school = schools.createSchool("School").getId();
|
||||||
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||||
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
|
var show = schools.createShow(
|
||||||
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
|
school,
|
||||||
|
"Show",
|
||||||
|
List.of(DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build()),
|
||||||
|
null);
|
||||||
var scheduling = new SchedulingService();
|
var scheduling = new SchedulingService();
|
||||||
scheduling.schools = schools;
|
scheduling.schools = schools;
|
||||||
scheduling.engine = new OrToolsSchedulingEngine();
|
scheduling.engine = new OrToolsSchedulingEngine();
|
||||||
scheduling.schedule(school, show.getId());
|
scheduling.schedule(school, show.getId());
|
||||||
var before = schools.exportSchool(school);
|
var before = schools.exportSchool(school);
|
||||||
for (SchedulingEngine failure : List.<SchedulingEngine>of(
|
for (SchedulingEngine failure : List.<SchedulingEngine>of(
|
||||||
input -> { throw new IllegalStateException("Native failure"); },
|
input -> {
|
||||||
input -> { throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); },
|
throw new IllegalStateException("Native failure");
|
||||||
|
},
|
||||||
|
input -> {
|
||||||
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit");
|
||||||
|
},
|
||||||
input -> null)) {
|
input -> null)) {
|
||||||
scheduling.engine = failure;
|
scheduling.engine = failure;
|
||||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
|
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
|
||||||
@@ -34,28 +48,46 @@ class SchedulingServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
@Test
|
||||||
|
void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||||
var schools = new SchoolManagementService();
|
var schools = new SchoolManagementService();
|
||||||
long school = schools.createSchool("School").getId();
|
long school = schools.createSchool("School").getId();
|
||||||
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||||
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
|
var show = schools.createShow(
|
||||||
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
|
school,
|
||||||
var scheduling = new SchedulingService(); scheduling.schools = schools;
|
"Show",
|
||||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
List.of(DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build()),
|
||||||
|
null);
|
||||||
|
var scheduling = new SchedulingService();
|
||||||
|
scheduling.schools = schools;
|
||||||
|
var entered = new CountDownLatch(1);
|
||||||
|
var release = new CountDownLatch(1);
|
||||||
scheduling.engine = input -> {
|
scheduling.engine = input -> {
|
||||||
entered.countDown();
|
entered.countDown();
|
||||||
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); }
|
try {
|
||||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
|
if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout");
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
return new OrToolsSchedulingEngine().solve(input);
|
return new OrToolsSchedulingEngine().solve(input);
|
||||||
};
|
};
|
||||||
try (var executor = Executors.newSingleThreadExecutor()) {
|
try (var executor = Executors.newSingleThreadExecutor()) {
|
||||||
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
|
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
|
||||||
try {
|
try {
|
||||||
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
||||||
assertEquals(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class,
|
assertEquals(
|
||||||
() -> scheduling.schedule(school, show.getId())).getKind());
|
ManagementExceptionKind.CONFLICT,
|
||||||
|
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
||||||
|
.getKind());
|
||||||
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||||
} finally { release.countDown(); }
|
} finally {
|
||||||
|
release.countDown();
|
||||||
|
}
|
||||||
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
|
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
|
||||||
assertInstanceOf(ManagementException.class, exception.getCause());
|
assertInstanceOf(ManagementException.class, exception.getCause());
|
||||||
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
|
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
|
||||||
|
|||||||
@@ -20,7 +20,5 @@ class DanceSchoolSit {
|
|||||||
@Test
|
@Test
|
||||||
void testDanceSchoolModel() {
|
void testDanceSchoolModel() {
|
||||||
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
|
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
ci/Ci.java
17
ci/Ci.java
@@ -24,16 +24,18 @@ public class Ci {
|
|||||||
case "attempt" -> {
|
case "attempt" -> {
|
||||||
Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
|
Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
|
||||||
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
|
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
|
||||||
.withZone(ZoneOffset.UTC).format(Instant.now());
|
.withZone(ZoneOffset.UTC)
|
||||||
Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
.format(Instant.now());
|
||||||
|
Files.writeString(
|
||||||
|
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
}
|
}
|
||||||
case "configure-gitea" -> Setup.configureGitea(env);
|
case "configure-gitea" -> Setup.configureGitea(env);
|
||||||
case "configure-docker" -> {
|
case "configure-docker" -> {
|
||||||
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
|
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
|
||||||
Setup.configureDocker(Path.of(args[1]));
|
Setup.configureDocker(Path.of(args[1]));
|
||||||
}
|
}
|
||||||
case "install-runner" -> Setup.installRunner(root, env, commands,
|
case "install-runner" ->
|
||||||
args.length == 2 && args[1].equals("--prepare-only"));
|
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
|
||||||
case "test" -> CiTests.run();
|
case "test" -> CiTests.run();
|
||||||
default -> throw new IllegalArgumentException(usage());
|
default -> throw new IllegalArgumentException(usage());
|
||||||
}
|
}
|
||||||
@@ -42,13 +44,16 @@ public class Ci {
|
|||||||
System.exit(e.exitCode);
|
System.exit(e.exitCode);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Never dump HTTP bodies, process arguments, environment or tokens.
|
// Never dump HTTP bodies, process arguments, environment or tokens.
|
||||||
System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage()
|
System.err.println(
|
||||||
|
e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure
|
||||||
|
? e.getMessage()
|
||||||
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
|
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String usage() {
|
static String usage() {
|
||||||
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker <path>|install-runner [--prepare-only]";
|
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
|
||||||
|
+ " <path>|install-runner [--prepare-only]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
260
ci/CiTests.java
260
ci/CiTests.java
@@ -6,26 +6,41 @@ import java.util.*;
|
|||||||
class CiTests {
|
class CiTests {
|
||||||
static final String SHA = "a".repeat(40);
|
static final String SHA = "a".repeat(40);
|
||||||
static int count;
|
static int count;
|
||||||
interface Test { void run() throws Exception; }
|
|
||||||
static void test(String name, Test test) throws Exception {
|
interface Test {
|
||||||
test.run(); count++; System.out.println("PASS " + name);
|
void run() throws Exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test(String name, Test test) throws Exception {
|
||||||
|
test.run();
|
||||||
|
count++;
|
||||||
|
System.out.println("PASS " + name);
|
||||||
|
}
|
||||||
|
|
||||||
static void equal(Object expected, Object actual) {
|
static void equal(Object expected, Object actual) {
|
||||||
if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
|
if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void failure(Test action) throws Exception {
|
static void failure(Test action) throws Exception {
|
||||||
try { action.run(); } catch (Exception expected) { return; }
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (Exception expected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new AssertionError("Expected failure");
|
throw new AssertionError("Expected failure");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void run() throws Exception {
|
static void run() throws Exception {
|
||||||
count = 0;
|
count = 0;
|
||||||
test("JSON preserves configuration values and escaped text", () -> {
|
test("JSON preserves configuration values and escaped text", () -> {
|
||||||
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
|
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
|
||||||
Object data = Json.parse(json); equal(data,Json.parse(Json.write(data)));
|
Object data = Json.parse(json);
|
||||||
|
equal(data, Json.parse(Json.write(data)));
|
||||||
equal("line\nquote\"€", Json.object(data).get("text"));
|
equal("line\nquote\"€", Json.object(data).get("text"));
|
||||||
});
|
});
|
||||||
test("JSON rejects malformed/ambiguous input", () -> {
|
test("JSON rejects malformed/ambiguous input", () -> {
|
||||||
for (String value : List.of("{\"a\":null,\"a\":1}","{} []","[1,]","01","\"\\uZZZZ\"","\"line\n\"","truefalse","+1"))
|
for (String value : List.of(
|
||||||
|
"{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1"))
|
||||||
failure(() -> Json.parse(value));
|
failure(() -> Json.parse(value));
|
||||||
});
|
});
|
||||||
test("Docker configuration merge is idempotent and preserves backup", () -> {
|
test("Docker configuration merge is idempotent and preserves backup", () -> {
|
||||||
@@ -34,135 +49,266 @@ class CiTests {
|
|||||||
Path file = dir.resolve("daemon.json");
|
Path file = dir.resolve("daemon.json");
|
||||||
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
|
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
|
||||||
Files.writeString(file, original);
|
Files.writeString(file, original);
|
||||||
Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file);
|
Setup.configureDocker(file);
|
||||||
|
String first = Files.readString(file);
|
||||||
|
Setup.configureDocker(file);
|
||||||
equal(first, Files.readString(file));
|
equal(first, Files.readString(file));
|
||||||
equal(List.of("existing:5000","192.168.2.28:3000"),Json.object(Json.parse(first)).get("insecure-registries"));
|
equal(
|
||||||
|
List.of("existing:5000", "192.168.2.28:3000"),
|
||||||
|
Json.object(Json.parse(first)).get("insecure-registries"));
|
||||||
equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers")));
|
equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers")));
|
||||||
} finally { Pipeline.deleteTree(dir); }
|
} finally {
|
||||||
|
Pipeline.deleteTree(dir);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
test("Invalid Docker configuration is unchanged", () -> {
|
test("Invalid Docker configuration is unchanged", () -> {
|
||||||
Path dir = Files.createTempDirectory("ci-docker-test-");
|
Path dir = Files.createTempDirectory("ci-docker-test-");
|
||||||
try {
|
try {
|
||||||
Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}";
|
Path file = dir.resolve("daemon.json");
|
||||||
Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file));
|
String original = "{\"insecure-registries\":\"invalid\"}";
|
||||||
} finally { Pipeline.deleteTree(dir); }
|
Files.writeString(file, original);
|
||||||
|
failure(() -> Setup.configureDocker(file));
|
||||||
|
equal(original, Files.readString(file));
|
||||||
|
} finally {
|
||||||
|
Pipeline.deleteTree(dir);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
test("Feature branches never contact registry", () -> {
|
test("Feature branches never contact registry", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.pipeline.env.put("CI_REF","refs/heads/feature/test"); f.pipeline.publish(); equal(0L,f.calls("login")); equal(0L,f.calls("push"));
|
f.pipeline.env.put("CI_REF", "refs/heads/feature/test");
|
||||||
|
f.pipeline.publish();
|
||||||
|
equal(0L, f.calls("login"));
|
||||||
|
equal(0L, f.calls("push"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Unverified commit cannot publish", () -> {
|
test("Unverified commit cannot publish", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
Files.delete(f.root.resolve("ci/artifacts/verified-commit")); failure(f.pipeline::publish); equal(0L,f.calls("push"));
|
Files.delete(f.root.resolve("ci/artifacts/verified-commit"));
|
||||||
|
failure(f.pipeline::publish);
|
||||||
|
equal(0L, f.calls("push"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Publication pullback and rerun preserve commit tags", () -> {
|
test("Publication pullback and rerun preserve commit tags", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.pipeline.publish(); equal(4L,f.calls("push")); f.pipeline.publish(); equal(6L,f.calls("push")); equal(8L,f.calls("pull"));
|
f.pipeline.publish();
|
||||||
|
equal(4L, f.calls("push"));
|
||||||
|
f.pipeline.publish();
|
||||||
|
equal(6L, f.calls("push"));
|
||||||
|
equal(8L, f.calls("pull"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Partial publication is completed without overwriting backend", () -> {
|
test("Partial publication is completed without overwriting backend", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish);
|
f.fake.pushFailure = "frontend:" + SHA;
|
||||||
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
|
failure(f.pipeline::publish);
|
||||||
f.fake.pushFailure = ""; f.pipeline.publish();
|
equal(
|
||||||
equal(1L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)).count());
|
0L,
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
|
||||||
|
.count());
|
||||||
|
f.fake.pushFailure = "";
|
||||||
|
f.pipeline.publish();
|
||||||
|
equal(
|
||||||
|
1L,
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA))
|
||||||
|
.count());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Authentication error is not a missing tag", () -> {
|
test("Authentication error is not a missing tag", () -> {
|
||||||
try (Fixture f = new Fixture()) { f.fake.manifestError = true; failure(f.pipeline::publish); equal(0L,f.calls("push")); }
|
try (Fixture f = new Fixture()) {
|
||||||
|
f.fake.manifestError = true;
|
||||||
|
failure(f.pipeline::publish);
|
||||||
|
equal(0L, f.calls("push"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
test("Pull failure blocks moving tags", () -> {
|
test("Pull failure blocks moving tags", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish);
|
f.fake.pullFailure = "backend:" + SHA;
|
||||||
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
|
failure(f.pipeline::publish);
|
||||||
|
equal(
|
||||||
|
0L,
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
|
||||||
|
.count());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Older commit cannot move main", () -> {
|
test("Older commit cannot move main", () -> {
|
||||||
try (Fixture f = new Fixture()) { f.fake.head = "c".repeat(40); f.pipeline.publish(); equal(2L,f.calls("push")); }
|
try (Fixture f = new Fixture()) {
|
||||||
|
f.fake.head = "c".repeat(40);
|
||||||
|
f.pipeline.publish();
|
||||||
|
equal(2L, f.calls("push"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
test("Unavailable Docker fails before build with a concrete cause", () -> {
|
test("Unavailable Docker fails before build with a concrete cause", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.fake.dockerAvailable = false;
|
f.fake.dockerAvailable = false;
|
||||||
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
|
try {
|
||||||
catch (IllegalArgumentException error) { equal(true,error.getMessage().contains("Docker is unavailable")); }
|
f.pipeline.verify();
|
||||||
equal(0L,f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).count());
|
throw new AssertionError("Expected failure");
|
||||||
|
} catch (IllegalArgumentException error) {
|
||||||
|
equal(true, error.getMessage().contains("Docker is unavailable"));
|
||||||
|
}
|
||||||
|
equal(
|
||||||
|
0L,
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.filter(a -> a.getFirst().equals("mvn"))
|
||||||
|
.count());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Architecture mismatch blocks publication", () -> {
|
test("Architecture mismatch blocks publication", () -> {
|
||||||
try (Fixture f = new Fixture()) { f.pipeline.env.put("TARGET_PLATFORM","linux/arm64"); failure(f.pipeline::publish); equal(0L,f.calls("push")); }
|
try (Fixture f = new Fixture()) {
|
||||||
|
f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64");
|
||||||
|
failure(f.pipeline::publish);
|
||||||
|
equal(0L, f.calls("push"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
test("Failed verification removes success marker and preserves exit status", () -> {
|
test("Failed verification removes success marker and preserves exit status", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.fake.mavenFailure = true;
|
f.fake.mavenFailure = true;
|
||||||
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
|
try {
|
||||||
catch (Commands.Failure error) { equal(42,error.exitCode); }
|
f.pipeline.verify();
|
||||||
equal(List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
|
throw new AssertionError("Expected failure");
|
||||||
f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).findFirst().orElseThrow());
|
} catch (Commands.Failure error) {
|
||||||
|
equal(42, error.exitCode);
|
||||||
|
}
|
||||||
|
equal(
|
||||||
|
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.filter(a -> a.getFirst().equals("mvn"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow());
|
||||||
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
|
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
|
||||||
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
|
equal(0L, f.calls("build"));
|
||||||
|
equal(0L, f.calls("push"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
|
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.fake.lintFailure = true;
|
f.fake.lintFailure = true;
|
||||||
try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); }
|
try {
|
||||||
catch (Commands.Failure error) { equal(1,error.exitCode); }
|
f.pipeline.verifyFrontend();
|
||||||
|
throw new AssertionError("Expected failure");
|
||||||
|
} catch (Commands.Failure error) {
|
||||||
|
equal(1, error.exitCode);
|
||||||
|
}
|
||||||
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
|
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Frontend verification checks before testing and building", () -> {
|
test("Frontend verification checks before testing and building", () -> {
|
||||||
try (Fixture f = new Fixture()) {
|
try (Fixture f = new Fixture()) {
|
||||||
f.pipeline.verifyFrontend();
|
f.pipeline.verifyFrontend();
|
||||||
equal(List.of("ci", "run lint", "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", "run build"),
|
equal(
|
||||||
f.fake.calls.stream().map(a -> String.join(" ", a.subList(1,a.size()))).toList());
|
List.of(
|
||||||
|
"ci",
|
||||||
|
"run lint",
|
||||||
|
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
|
||||||
|
"run build"),
|
||||||
|
f.fake.calls.stream()
|
||||||
|
.map(a -> String.join(" ", a.subList(1, a.size())))
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Process execution enforces its deadline", () -> {
|
test("Process execution enforces its deadline", () -> {
|
||||||
Path dir = Files.createTempDirectory("ci-process-test-");
|
Path dir = Files.createTempDirectory("ci-process-test-");
|
||||||
try {
|
try {
|
||||||
Path file = dir.resolve("Sleep.java");
|
Path file = dir.resolve("Sleep.java");
|
||||||
Files.writeString(file,"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000); } }");
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);"
|
||||||
|
+ " } }");
|
||||||
Commands runner = new Commands(Duration.ofMillis(100));
|
Commands runner = new Commands(Duration.ofMillis(100));
|
||||||
try {
|
try {
|
||||||
runner.execute(List.of(System.getProperty("java.home") + "/bin/java",file.toString()),dir,System.getenv(),null,false);
|
runner.execute(
|
||||||
|
List.of(System.getProperty("java.home") + "/bin/java", file.toString()),
|
||||||
|
dir,
|
||||||
|
System.getenv(),
|
||||||
|
null,
|
||||||
|
false);
|
||||||
throw new AssertionError("Expected timeout");
|
throw new AssertionError("Expected timeout");
|
||||||
} catch (Commands.Failure error) { equal(124,error.exitCode); }
|
} catch (Commands.Failure error) {
|
||||||
} finally { Pipeline.deleteTree(dir); }
|
equal(124, error.exitCode);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pipeline.deleteTree(dir);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
System.out.println(count + " CI tests passed");
|
System.out.println(count + " CI tests passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
static class Fixture implements AutoCloseable {
|
static class Fixture implements AutoCloseable {
|
||||||
final Path root = Files.createTempDirectory("ci-publication-test-");
|
final Path root = Files.createTempDirectory("ci-publication-test-");
|
||||||
final FakeCommands fake = new FakeCommands();
|
final FakeCommands fake = new FakeCommands();
|
||||||
final Pipeline pipeline;
|
final Pipeline pipeline;
|
||||||
|
|
||||||
Fixture() throws Exception {
|
Fixture() throws Exception {
|
||||||
Map<String, String> env = new HashMap<>(System.getenv());
|
Map<String, String> env = new HashMap<>(System.getenv());
|
||||||
env.putAll(Map.of("CI_COMMIT",SHA,"CI_RUN_ID","test","CI_RUN_ATTEMPT","1","CI_REF","refs/heads/main",
|
env.putAll(Map.of(
|
||||||
"CI_REPOSITORY","wanjakrah/agiledancers","TARGET_PLATFORM","linux/amd64","GITEA_SERVER_URL","http://192.168.2.28:3000",
|
"CI_COMMIT",
|
||||||
"REGISTRY_USER","test","REGISTRY_TOKEN","dummy-token","CI_PUBLISH_LOCK_DIR",root.resolve("lock").toString()));
|
SHA,
|
||||||
pipeline = new Pipeline(root,env,fake) { @Override String mainHead() { return fake.head; } };
|
"CI_RUN_ID",
|
||||||
|
"test",
|
||||||
|
"CI_RUN_ATTEMPT",
|
||||||
|
"1",
|
||||||
|
"CI_REF",
|
||||||
|
"refs/heads/main",
|
||||||
|
"CI_REPOSITORY",
|
||||||
|
"wanjakrah/agiledancers",
|
||||||
|
"TARGET_PLATFORM",
|
||||||
|
"linux/amd64",
|
||||||
|
"GITEA_SERVER_URL",
|
||||||
|
"http://192.168.2.28:3000",
|
||||||
|
"REGISTRY_USER",
|
||||||
|
"test",
|
||||||
|
"REGISTRY_TOKEN",
|
||||||
|
"dummy-token",
|
||||||
|
"CI_PUBLISH_LOCK_DIR",
|
||||||
|
root.resolve("lock").toString()));
|
||||||
|
pipeline = new Pipeline(root, env, fake) {
|
||||||
|
@Override
|
||||||
|
String mainHead() {
|
||||||
|
return fake.head;
|
||||||
|
}
|
||||||
|
};
|
||||||
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
|
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
|
||||||
}
|
}
|
||||||
long calls(String verb) { return fake.calls.stream().filter(a -> a.size()>1 && a.getFirst().equals("docker") && a.get(1).equals(verb)).count(); }
|
|
||||||
public void close() throws Exception { pipeline.closeDockerConfig(); Pipeline.deleteTree(root); }
|
long calls(String verb) {
|
||||||
|
return fake.calls.stream()
|
||||||
|
.filter(a -> a.size() > 1
|
||||||
|
&& a.getFirst().equals("docker")
|
||||||
|
&& a.get(1).equals(verb))
|
||||||
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void close() throws Exception {
|
||||||
|
pipeline.closeDockerConfig();
|
||||||
|
Pipeline.deleteTree(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static class FakeCommands extends Commands {
|
static class FakeCommands extends Commands {
|
||||||
final List<List<String>> calls = new ArrayList<>();
|
final List<List<String>> calls = new ArrayList<>();
|
||||||
final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
|
final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
|
||||||
String head = SHA, pushFailure = "", pullFailure = "";
|
String head = SHA, pushFailure = "", pullFailure = "";
|
||||||
boolean manifestError, mavenFailure, lintFailure;
|
boolean manifestError, mavenFailure, lintFailure;
|
||||||
boolean dockerAvailable = true;
|
boolean dockerAvailable = true;
|
||||||
FakeCommands() { super(Duration.ofMinutes(1)); }
|
|
||||||
@Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) {
|
FakeCommands() {
|
||||||
|
super(Duration.ofMinutes(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
|
||||||
calls.add(List.copyOf(args));
|
calls.add(List.copyOf(args));
|
||||||
if (args.getFirst().equals("git")) return new Result(0, SHA);
|
if (args.getFirst().equals("git")) return new Result(0, SHA);
|
||||||
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
|
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
|
||||||
if (args.equals(List.of("npm", "run", "lint")) && lintFailure) return new Result(1,"deliberate lint failure");
|
if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
|
||||||
|
return new Result(1, "deliberate lint failure");
|
||||||
if (!args.getFirst().equals("docker")) return new Result(0, "");
|
if (!args.getFirst().equals("docker")) return new Result(0, "");
|
||||||
String command = args.get(1), ref = args.getLast();
|
String command = args.get(1), ref = args.getLast();
|
||||||
if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux");
|
if (command.equals("info"))
|
||||||
|
return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
|
||||||
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
|
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
|
||||||
if (command.equals("manifest")) {
|
if (command.equals("manifest")) {
|
||||||
if (manifestError) return new Result(1, "unauthorized");
|
if (manifestError) return new Result(1, "unauthorized");
|
||||||
@@ -173,10 +319,16 @@ class CiTests {
|
|||||||
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
|
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
|
||||||
images.put(ref, tags.getOrDefault(ref, SHA));
|
images.put(ref, tags.getOrDefault(ref, SHA));
|
||||||
}
|
}
|
||||||
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) return new Result(1,"pull failed");
|
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
|
||||||
if (command.equals("image")) return new Result(0,args.stream().anyMatch(a -> a.contains("RepoDigests"))
|
return new Result(1, "pull failed");
|
||||||
? ref.substring(0,ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) : images.getOrDefault(ref,"missing"));
|
if (command.equals("image"))
|
||||||
if (command.equals("container") || command.equals("network") && args.contains("inspect")) return new Result(1,"");
|
return new Result(
|
||||||
|
0,
|
||||||
|
args.stream().anyMatch(a -> a.contains("RepoDigests"))
|
||||||
|
? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64)
|
||||||
|
: images.getOrDefault(ref, "missing"));
|
||||||
|
if (command.equals("container") || command.equals("network") && args.contains("inspect"))
|
||||||
|
return new Result(1, "");
|
||||||
return new Result(0, "");
|
return new Result(0, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,37 @@ class Commands {
|
|||||||
return output.strip();
|
return output.strip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static class Failure extends RuntimeException {
|
static class Failure extends RuntimeException {
|
||||||
final int exitCode;
|
final int exitCode;
|
||||||
Failure(int exitCode, String message) { super(message); this.exitCode = exitCode; }
|
|
||||||
|
Failure(int exitCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.exitCode = exitCode;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Instant deadline;
|
Instant deadline;
|
||||||
volatile Process active;
|
volatile Process active;
|
||||||
Path log;
|
Path log;
|
||||||
void renew(Duration timeout) { deadline = Instant.now().plus(timeout); }
|
|
||||||
void cancel() { Process process = active; if (process != null) stop(process); }
|
void renew(Duration timeout) {
|
||||||
Commands(Duration timeout) { deadline = Instant.now().plus(timeout); }
|
deadline = Instant.now().plus(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
Process process = active;
|
||||||
|
if (process != null) stop(process);
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands(Duration timeout) {
|
||||||
|
deadline = Instant.now().plus(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception {
|
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception {
|
||||||
if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded");
|
if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded");
|
||||||
ProcessBuilder builder = new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
|
ProcessBuilder builder =
|
||||||
|
new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
|
||||||
builder.environment().clear();
|
builder.environment().clear();
|
||||||
builder.environment().putAll(env);
|
builder.environment().putAll(env);
|
||||||
Process process = builder.start();
|
Process process = builder.start();
|
||||||
@@ -36,22 +53,30 @@ class Commands {
|
|||||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
Future<?> reader = executor.submit(() -> {
|
Future<?> reader = executor.submit(() -> {
|
||||||
try (InputStream stream = process.getInputStream();
|
try (InputStream stream = process.getInputStream();
|
||||||
OutputStream file = destination == null ? OutputStream.nullOutputStream()
|
OutputStream file = destination == null
|
||||||
: Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
|
? OutputStream.nullOutputStream()
|
||||||
|
: Files.newOutputStream(
|
||||||
|
destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
int count;
|
int count;
|
||||||
while ((count = stream.read(buffer)) != -1) {
|
while ((count = stream.read(buffer)) != -1) {
|
||||||
captured.write(buffer, 0, count);
|
captured.write(buffer, 0, count);
|
||||||
if (echo) { System.out.write(buffer, 0, count); System.out.flush(); }
|
if (echo) {
|
||||||
|
System.out.write(buffer, 0, count);
|
||||||
|
System.out.flush();
|
||||||
|
}
|
||||||
file.write(buffer, 0, count);
|
file.write(buffer, 0, count);
|
||||||
}
|
}
|
||||||
} catch (IOException e) { throw new UncheckedIOException(e); }
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
try (OutputStream stdin = process.getOutputStream()) {
|
try (OutputStream stdin = process.getOutputStream()) {
|
||||||
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
|
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
if (!process.waitFor(Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
|
if (!process.waitFor(
|
||||||
|
Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
|
||||||
stop(process);
|
stop(process);
|
||||||
throw new Failure(124, "CI time limit exceeded");
|
throw new Failure(124, "CI time limit exceeded");
|
||||||
}
|
}
|
||||||
|
|||||||
104
ci/Json.java
104
ci/Json.java
@@ -5,67 +5,110 @@ import java.util.*;
|
|||||||
final class Json {
|
final class Json {
|
||||||
static Object parse(String source) {
|
static Object parse(String source) {
|
||||||
Parser parser = new Parser(source);
|
Parser parser = new Parser(source);
|
||||||
Object result = parser.value(); parser.space();
|
Object result = parser.value();
|
||||||
|
parser.space();
|
||||||
if (parser.pos != source.length()) throw parser.invalid();
|
if (parser.pos != source.length()) throw parser.invalid();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
static Map<String, Object> object(Object value) {
|
static Map<String, Object> object(Object value) {
|
||||||
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
|
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
|
||||||
return (Map<String, Object>) value;
|
return (Map<String, Object>) value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String write(Object value) {
|
static String write(Object value) {
|
||||||
if (value == null) return "null";
|
if (value == null) return "null";
|
||||||
if (value instanceof String text) {
|
if (value instanceof String text) {
|
||||||
StringBuilder out = new StringBuilder("\"");
|
StringBuilder out = new StringBuilder("\"");
|
||||||
for (char c : text.toCharArray()) {
|
for (char c : text.toCharArray()) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\");
|
case '"' -> out.append("\\\"");
|
||||||
case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t");
|
case '\\' -> out.append("\\\\");
|
||||||
default -> { if (c < 32) out.append(String.format("\\u%04x", (int)c)); else out.append(c); }
|
case '\n' -> out.append("\\n");
|
||||||
|
case '\r' -> out.append("\\r");
|
||||||
|
case '\t' -> out.append("\\t");
|
||||||
|
default -> {
|
||||||
|
if (c < 32) out.append(String.format("\\u%04x", (int) c));
|
||||||
|
else out.append(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.append('"').toString();
|
return out.append('"').toString();
|
||||||
}
|
}
|
||||||
if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long)
|
if (value instanceof Boolean
|
||||||
return value.toString();
|
|| value instanceof BigDecimal
|
||||||
|
|| value instanceof Integer
|
||||||
|
|| value instanceof Long) return value.toString();
|
||||||
if (value instanceof Map<?, ?> map) {
|
if (value instanceof Map<?, ?> map) {
|
||||||
StringJoiner out = new StringJoiner(",", "{", "}");
|
StringJoiner out = new StringJoiner(",", "{", "}");
|
||||||
map.forEach((k,v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); return out.toString();
|
map.forEach((k, v) -> out.add(write(Objects.toString(k)) + ":" + write(v)));
|
||||||
|
return out.toString();
|
||||||
}
|
}
|
||||||
if (value instanceof List<?> list) {
|
if (value instanceof List<?> list) {
|
||||||
StringJoiner out = new StringJoiner(",", "[", "]"); list.forEach(v -> out.add(write(v))); return out.toString();
|
StringJoiner out = new StringJoiner(",", "[", "]");
|
||||||
|
list.forEach(v -> out.add(write(v)));
|
||||||
|
return out.toString();
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Unsupported JSON value");
|
throw new IllegalArgumentException("Unsupported JSON value");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Parser {
|
private static final class Parser {
|
||||||
final String source; int pos;
|
final String source;
|
||||||
Parser(String source) { this.source = source; }
|
int pos;
|
||||||
IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at offset " + pos); }
|
|
||||||
void space() { while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; }
|
Parser(String source) {
|
||||||
boolean take(char c) { space(); if (pos < source.length() && source.charAt(pos) == c) { pos++; return true; } return false; }
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
IllegalArgumentException invalid() {
|
||||||
|
return new IllegalArgumentException("Invalid JSON at offset " + pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
void space() {
|
||||||
|
while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean take(char c) {
|
||||||
|
space();
|
||||||
|
if (pos < source.length() && source.charAt(pos) == c) {
|
||||||
|
pos++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Object value() {
|
Object value() {
|
||||||
space(); if (pos >= source.length()) throw invalid();
|
space();
|
||||||
|
if (pos >= source.length()) throw invalid();
|
||||||
char c = source.charAt(pos);
|
char c = source.charAt(pos);
|
||||||
if (c == '"') return string();
|
if (c == '"') return string();
|
||||||
if (take('{')) {
|
if (take('{')) {
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
if (take('}')) return out;
|
if (take('}')) return out;
|
||||||
do {
|
do {
|
||||||
space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
|
space();
|
||||||
String key = string(); if (!take(':') || out.containsKey(key)) throw invalid();
|
if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
|
||||||
|
String key = string();
|
||||||
|
if (!take(':') || out.containsKey(key)) throw invalid();
|
||||||
out.put(key, value());
|
out.put(key, value());
|
||||||
} while (take(','));
|
} while (take(','));
|
||||||
if (!take('}')) throw invalid(); return out;
|
if (!take('}')) throw invalid();
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
if (take('[')) {
|
if (take('[')) {
|
||||||
List<Object> out = new ArrayList<>(); if (take(']')) return out;
|
List<Object> out = new ArrayList<>();
|
||||||
do { out.add(value()); } while (take(','));
|
if (take(']')) return out;
|
||||||
if (!take(']')) throw invalid(); return out;
|
do {
|
||||||
|
out.add(value());
|
||||||
|
} while (take(','));
|
||||||
|
if (!take(']')) throw invalid();
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
for (String token : List.of("true", "false", "null")) {
|
for (String token : List.of("true", "false", "null")) {
|
||||||
if (source.startsWith(token, pos)) {
|
if (source.startsWith(token, pos)) {
|
||||||
pos += token.length(); return token.equals("null") ? null : Boolean.valueOf(token);
|
pos += token.length();
|
||||||
|
return token.equals("null") ? null : Boolean.valueOf(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
int start = pos;
|
int start = pos;
|
||||||
@@ -74,24 +117,33 @@ final class Json {
|
|||||||
if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid();
|
if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid();
|
||||||
return new BigDecimal(number);
|
return new BigDecimal(number);
|
||||||
}
|
}
|
||||||
|
|
||||||
String string() {
|
String string() {
|
||||||
pos++; StringBuilder out = new StringBuilder();
|
pos++;
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
while (pos < source.length()) {
|
while (pos < source.length()) {
|
||||||
char c = source.charAt(pos++);
|
char c = source.charAt(pos++);
|
||||||
if (c == '"') return out.toString();
|
if (c == '"') return out.toString();
|
||||||
if (c < 32) throw invalid();
|
if (c < 32) throw invalid();
|
||||||
if (c != '\\') { out.append(c); continue; }
|
if (c != '\\') {
|
||||||
|
out.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (pos >= source.length()) throw invalid();
|
if (pos >= source.length()) throw invalid();
|
||||||
char escaped = source.charAt(pos++);
|
char escaped = source.charAt(pos++);
|
||||||
switch (escaped) {
|
switch (escaped) {
|
||||||
case '"', '\\', '/' -> out.append(escaped);
|
case '"', '\\', '/' -> out.append(escaped);
|
||||||
case 'b' -> out.append('\b'); case 'f' -> out.append('\f');
|
case 'b' -> out.append('\b');
|
||||||
case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t');
|
case 'f' -> out.append('\f');
|
||||||
|
case 'n' -> out.append('\n');
|
||||||
|
case 'r' -> out.append('\r');
|
||||||
|
case 't' -> out.append('\t');
|
||||||
case 'u' -> {
|
case 'u' -> {
|
||||||
if (pos + 4 > source.length()) throw invalid();
|
if (pos + 4 > source.length()) throw invalid();
|
||||||
String digits = source.substring(pos, pos + 4);
|
String digits = source.substring(pos, pos + 4);
|
||||||
if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid();
|
if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid();
|
||||||
out.append((char) Integer.parseInt(digits,16)); pos += 4;
|
out.append((char) Integer.parseInt(digits, 16));
|
||||||
|
pos += 4;
|
||||||
}
|
}
|
||||||
default -> throw invalid();
|
default -> throw invalid();
|
||||||
}
|
}
|
||||||
|
|||||||
336
ci/Pipeline.java
336
ci/Pipeline.java
@@ -23,105 +23,263 @@ class Pipeline {
|
|||||||
runName = "agiledancers-" + id + "-" + attempt;
|
runName = "agiledancers-" + id + "-" + attempt;
|
||||||
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
|
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
|
||||||
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
||||||
artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts);
|
artifacts = root.resolve("ci/artifacts");
|
||||||
|
Files.createDirectories(artifacts);
|
||||||
}
|
}
|
||||||
interface Value { String get() throws Exception; }
|
|
||||||
|
interface Value {
|
||||||
|
String get() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
String value(String key, Value defaultValue) throws Exception {
|
String value(String key, Value defaultValue) throws Exception {
|
||||||
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
|
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
|
||||||
return env.get(key);
|
return env.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void check(boolean condition, String message) {
|
static void check(boolean condition, String message) {
|
||||||
if (!condition) throw new IllegalArgumentException(message);
|
if (!condition) throw new IllegalArgumentException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands.Result result(boolean echo, String... args) throws Exception {
|
Commands.Result result(boolean echo, String... args) throws Exception {
|
||||||
return commands.execute(List.of(args), root, env, null, echo);
|
return commands.execute(List.of(args), root, env, null, echo);
|
||||||
}
|
}
|
||||||
String capture(String... args) throws Exception { return result(false, args).checked(); }
|
|
||||||
void run(String... args) throws Exception { result(true, args).checked(); }
|
String capture(String... args) throws Exception {
|
||||||
|
return result(false, args).checked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void run(String... args) throws Exception {
|
||||||
|
result(true, args).checked();
|
||||||
|
}
|
||||||
|
|
||||||
void requirePlatform() throws Exception {
|
void requirePlatform() throws Exception {
|
||||||
String target = Setup.required(env, "TARGET_PLATFORM");
|
String target = Setup.required(env, "TARGET_PLATFORM");
|
||||||
var docker = result(false, "docker", "info", "--format", "{{.Architecture}}");
|
var docker = result(false, "docker", "info", "--format", "{{.Architecture}}");
|
||||||
check(docker.code() == 0, "Docker is unavailable; start Docker Desktop before running CI (details in verify.log)");
|
check(
|
||||||
|
docker.code() == 0,
|
||||||
|
"Docker is unavailable; start Docker Desktop before running CI (details in verify.log)");
|
||||||
String arch = docker.output().strip();
|
String arch = docker.output().strip();
|
||||||
arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; };
|
arch = switch (arch) {
|
||||||
|
case "x86_64" -> "amd64";
|
||||||
|
case "aarch64" -> "arm64";
|
||||||
|
default -> arch;
|
||||||
|
};
|
||||||
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
|
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
|
||||||
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
|
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
|
||||||
}
|
}
|
||||||
|
|
||||||
void isolateDocker() throws Exception {
|
void isolateDocker() throws Exception {
|
||||||
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
||||||
env.remove("DOCKER_CONTEXT");
|
env.remove("DOCKER_CONTEXT");
|
||||||
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
|
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
|
||||||
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
void verify() throws Exception {
|
void verify() throws Exception {
|
||||||
Files.deleteIfExists(artifacts.resolve("verified-commit"));
|
Files.deleteIfExists(artifacts.resolve("verified-commit"));
|
||||||
env.put("CI", "true");
|
env.put("CI", "true");
|
||||||
commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, "");
|
commands.log = artifacts.resolve("verify.log");
|
||||||
Thread shutdown = new Thread(() -> {
|
Files.writeString(commands.log, "");
|
||||||
|
Thread shutdown = new Thread(
|
||||||
|
() -> {
|
||||||
commands.cancel();
|
commands.cancel();
|
||||||
try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); }
|
try {
|
||||||
}, "ci-cleanup");
|
cleanup();
|
||||||
|
closeDockerConfig();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("CI shutdown cleanup failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ci-cleanup");
|
||||||
Runtime.getRuntime().addShutdownHook(shutdown);
|
Runtime.getRuntime().addShutdownHook(shutdown);
|
||||||
Exception failure = null;
|
Exception failure = null;
|
||||||
try {
|
try {
|
||||||
requirePlatform(); isolateDocker();
|
requirePlatform();
|
||||||
|
isolateDocker();
|
||||||
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
||||||
run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
|
run("java", "-version");
|
||||||
|
run("node", "--version");
|
||||||
|
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
|
||||||
CiTests.run();
|
CiTests.run();
|
||||||
Path frontend = root.resolve("agiledancers-fe");
|
Path frontend = root.resolve("agiledancers-fe");
|
||||||
verifyFrontend();
|
verifyFrontend();
|
||||||
String platform = env.get("TARGET_PLATFORM");
|
String platform = env.get("TARGET_PLATFORM");
|
||||||
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
run(
|
||||||
"org.opencontainers.image.revision=" + commit, "-t", backendImage, ".");
|
"docker",
|
||||||
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
"build",
|
||||||
"org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe");
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--target",
|
||||||
|
"prebuilt",
|
||||||
|
"--label",
|
||||||
|
"org.opencontainers.image.revision=" + commit,
|
||||||
|
"-t",
|
||||||
|
backendImage,
|
||||||
|
".");
|
||||||
|
run(
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--target",
|
||||||
|
"prebuilt",
|
||||||
|
"--label",
|
||||||
|
"org.opencontainers.image.revision=" + commit,
|
||||||
|
"-t",
|
||||||
|
frontendImage,
|
||||||
|
"agiledancers-fe");
|
||||||
Files.writeString(artifacts.resolve("containers-started"), "");
|
Files.writeString(artifacts.resolve("containers-started"), "");
|
||||||
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
||||||
run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName,
|
run(
|
||||||
"--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage);
|
"docker",
|
||||||
run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.ci.run=" + runName,
|
"run",
|
||||||
"--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage);
|
"-d",
|
||||||
|
"--name",
|
||||||
|
runName + "-backend",
|
||||||
|
"--label",
|
||||||
|
"agiledancers.ci.run=" + runName,
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--network",
|
||||||
|
runName,
|
||||||
|
"--network-alias",
|
||||||
|
"backend",
|
||||||
|
"--memory",
|
||||||
|
"1g",
|
||||||
|
"--cpus",
|
||||||
|
"2",
|
||||||
|
backendImage);
|
||||||
|
run(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
runName + "-frontend",
|
||||||
|
"--label",
|
||||||
|
"agiledancers.ci.run=" + runName,
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--network",
|
||||||
|
runName,
|
||||||
|
"--network-alias",
|
||||||
|
"frontend",
|
||||||
|
"--memory",
|
||||||
|
"256m",
|
||||||
|
"--cpus",
|
||||||
|
"1",
|
||||||
|
frontendImage);
|
||||||
waitForApplication();
|
waitForApplication();
|
||||||
String user = capture("id", "-u") + ":" + capture("id", "-g");
|
String user = capture("id", "-u") + ":" + capture("id", "-g");
|
||||||
run("docker", "run", "--name", runName + "-browser", "--label", "agiledancers.ci.run=" + runName,
|
run(
|
||||||
"--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g",
|
"docker",
|
||||||
"--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080",
|
"run",
|
||||||
"--volume", frontend + ":/workspace", "--workdir", "/workspace", "mcr.microsoft.com/playwright:v1.64.1-noble",
|
"--name",
|
||||||
"npm", "run", "e2e");
|
runName + "-browser",
|
||||||
|
"--label",
|
||||||
|
"agiledancers.ci.run=" + runName,
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
"--network",
|
||||||
|
runName,
|
||||||
|
"--memory",
|
||||||
|
"2g",
|
||||||
|
"--cpus",
|
||||||
|
"2",
|
||||||
|
"--shm-size=1g",
|
||||||
|
"--user",
|
||||||
|
user,
|
||||||
|
"--env",
|
||||||
|
"HOME=/tmp",
|
||||||
|
"--env",
|
||||||
|
"CI=true",
|
||||||
|
"--env",
|
||||||
|
"PLAYWRIGHT_BASE_URL=http://frontend:8080",
|
||||||
|
"--volume",
|
||||||
|
frontend + ":/workspace",
|
||||||
|
"--workdir",
|
||||||
|
"/workspace",
|
||||||
|
"mcr.microsoft.com/playwright:v1.64.1-noble",
|
||||||
|
"npm",
|
||||||
|
"run",
|
||||||
|
"e2e");
|
||||||
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
||||||
} catch (Exception e) { failure = e; throw e; }
|
} catch (Exception e) {
|
||||||
finally {
|
failure = e;
|
||||||
try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; }
|
throw e;
|
||||||
finally {
|
} finally {
|
||||||
|
try {
|
||||||
|
cleanup();
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (failure != null) failure.addSuppressed(e);
|
||||||
|
else throw e;
|
||||||
|
} finally {
|
||||||
closeDockerConfig();
|
closeDockerConfig();
|
||||||
try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ }
|
try {
|
||||||
|
Runtime.getRuntime().removeShutdownHook(shutdown);
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
/* shutting down */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void verifyFrontend() throws Exception {
|
void verifyFrontend() throws Exception {
|
||||||
Path frontend = root.resolve("agiledancers-fe");
|
Path frontend = root.resolve("agiledancers-fe");
|
||||||
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
|
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
|
||||||
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true).checked();
|
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
|
||||||
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
|
.checked();
|
||||||
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked();
|
commands.execute(
|
||||||
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked();
|
List.of(
|
||||||
|
"npm",
|
||||||
|
"test",
|
||||||
|
"--",
|
||||||
|
"--reporters=default",
|
||||||
|
"--reporters=junit",
|
||||||
|
"--output-file=unit-results/junit.xml"),
|
||||||
|
frontend,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
|
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
|
||||||
|
.checked();
|
||||||
}
|
}
|
||||||
|
|
||||||
void waitForApplication() throws Exception {
|
void waitForApplication() throws Exception {
|
||||||
for (int i = 0; i < 60; i++) {
|
for (int i = 0; i < 60; i++) {
|
||||||
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",
|
if (result(
|
||||||
"http://127.0.0.1:8080/api/dance-schools").code() == 0) return;
|
false,
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
runName + "-frontend",
|
||||||
|
"wget",
|
||||||
|
"-q",
|
||||||
|
"-O",
|
||||||
|
"-",
|
||||||
|
"http://127.0.0.1:8080/api/dance-schools")
|
||||||
|
.code()
|
||||||
|
== 0) return;
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Application readiness timed out");
|
throw new IllegalArgumentException("Application readiness timed out");
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void cleanup() throws Exception {
|
synchronized void cleanup() throws Exception {
|
||||||
// A separate cleanup budget remains available after the build deadline.
|
// A separate cleanup budget remains available after the build deadline.
|
||||||
commands.renew(Duration.ofMinutes(2));
|
commands.renew(Duration.ofMinutes(2));
|
||||||
List<String> failures = new ArrayList<>();
|
List<String> failures = new ArrayList<>();
|
||||||
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports",
|
for (String path : List.of(
|
||||||
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) {
|
"agiledancers-be/target/surefire-reports",
|
||||||
|
"agiledancers-be/target/failsafe-reports",
|
||||||
|
"agiledancers-fe/unit-results",
|
||||||
|
"agiledancers-fe/test-results",
|
||||||
|
"agiledancers-fe/playwright-report")) {
|
||||||
if (Files.isDirectory(root.resolve(path))) {
|
if (Files.isDirectory(root.resolve(path))) {
|
||||||
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); }
|
try {
|
||||||
|
copyTree(root.resolve(path), artifacts.resolve(path));
|
||||||
|
} catch (IOException e) {
|
||||||
|
failures.add(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result(false, "docker", "info").code() == 0) {
|
if (result(false, "docker", "info").code() == 0) {
|
||||||
@@ -137,76 +295,129 @@ class Pipeline {
|
|||||||
if (result(false, "docker", "network", "inspect", runName).code() == 0
|
if (result(false, "docker", "network", "inspect", runName).code() == 0
|
||||||
&& result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
|
&& result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
|
||||||
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
|
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
|
||||||
} else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup");
|
} else if (Files.exists(artifacts.resolve("containers-started")))
|
||||||
|
failures.add("Docker unavailable during cleanup");
|
||||||
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
|
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
|
||||||
}
|
}
|
||||||
|
|
||||||
void publish() throws Exception {
|
void publish() throws Exception {
|
||||||
if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; }
|
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
|
||||||
check(Files.exists(artifacts.resolve("verified-commit"))
|
System.out.println("Branch is not main; no publication");
|
||||||
&& Files.readString(artifacts.resolve("verified-commit")).strip().equals(commit), "This commit has not passed verification");
|
return;
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
Files.exists(artifacts.resolve("verified-commit"))
|
||||||
|
&& Files.readString(artifacts.resolve("verified-commit"))
|
||||||
|
.strip()
|
||||||
|
.equals(commit),
|
||||||
|
"This commit has not passed verification");
|
||||||
String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN");
|
String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN");
|
||||||
String server = Setup.required(env, "GITEA_SERVER_URL");
|
String server = Setup.required(env, "GITEA_SERVER_URL");
|
||||||
String repository = Setup.required(env, "CI_REPOSITORY");
|
String repository = Setup.required(env, "CI_REPOSITORY");
|
||||||
requirePlatform();
|
requirePlatform();
|
||||||
commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, "");
|
commands.log = artifacts.resolve("publish.log");
|
||||||
|
Files.writeString(commands.log, "");
|
||||||
var uri = java.net.URI.create(server);
|
var uri = java.net.URI.create(server);
|
||||||
check(Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
check(
|
||||||
|
Set.of("http", "https").contains(uri.getScheme())
|
||||||
|
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
||||||
"Registry must be hosted at the Gitea server root");
|
"Registry must be hosted at the Gitea server root");
|
||||||
String registry = uri.getRawAuthority();
|
String registry = uri.getRawAuthority();
|
||||||
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
|
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
|
||||||
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
|
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
|
||||||
Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR",
|
Path lockDir = Path.of(env.getOrDefault(
|
||||||
env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
|
"CI_PUBLISH_LOCK_DIR",
|
||||||
|
env.getOrDefault(
|
||||||
|
"XDG_STATE_HOME",
|
||||||
|
Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
|
||||||
Files.createDirectories(lockDir);
|
Files.createDirectories(lockDir);
|
||||||
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
||||||
try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
try (FileChannel channel = FileChannel.open(
|
||||||
|
lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||||
FileLock lock = awaitLock(channel)) {
|
FileLock lock = awaitLock(channel)) {
|
||||||
isolateDocker();
|
isolateDocker();
|
||||||
try {
|
try {
|
||||||
commands.execute(List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true).checked();
|
commands.execute(
|
||||||
|
List.of("docker", "login", registry, "--username", user, "--password-stdin"),
|
||||||
|
root,
|
||||||
|
env,
|
||||||
|
token,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
String prefix = registry + "/" + owner + "/agiledancers-";
|
String prefix = registry + "/" + owner + "/agiledancers-";
|
||||||
ensureImage(prefix + "backend", backendImage);
|
ensureImage(prefix + "backend", backendImage);
|
||||||
ensureImage(prefix + "frontend", frontendImage);
|
ensureImage(prefix + "frontend", frontendImage);
|
||||||
for (String role : List.of("backend", "frontend")) {
|
for (String role : List.of("backend", "frontend")) {
|
||||||
if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; }
|
if (!commit.equals(mainHead())) {
|
||||||
|
System.out.println("Newer main exists; moving tags unchanged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String image = prefix + role;
|
String image = prefix + role;
|
||||||
run("docker", "tag", image + ":" + commit, image + ":main");
|
run("docker", "tag", image + ":" + commit, image + ":main");
|
||||||
run("docker", "push", image + ":main");
|
run("docker", "push", image + ":main");
|
||||||
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
|
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
|
||||||
}
|
}
|
||||||
} finally { closeDockerConfig(); }
|
} finally {
|
||||||
|
closeDockerConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FileLock awaitLock(FileChannel channel) throws Exception {
|
FileLock awaitLock(FileChannel channel) throws Exception {
|
||||||
Instant end = Instant.now().plusSeconds(600);
|
Instant end = Instant.now().plusSeconds(600);
|
||||||
while (Instant.now().isBefore(end)) {
|
while (Instant.now().isBefore(end)) {
|
||||||
FileLock lock = channel.tryLock(); if (lock != null) return lock;
|
FileLock lock = channel.tryLock();
|
||||||
|
if (lock != null) return lock;
|
||||||
Thread.sleep(250);
|
Thread.sleep(250);
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Publication lock timed out");
|
throw new IllegalArgumentException("Publication lock timed out");
|
||||||
}
|
}
|
||||||
|
|
||||||
String mainHead() throws Exception {
|
String mainHead() throws Exception {
|
||||||
Map<String,Object> branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"),
|
Map<String, Object> branch = Json.object(Setup.api(
|
||||||
env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null));
|
env.get("GITEA_SERVER_URL"),
|
||||||
|
env.get("REGISTRY_TOKEN"),
|
||||||
|
"GET",
|
||||||
|
"/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main",
|
||||||
|
null));
|
||||||
return Objects.toString(Json.object(branch.get("commit")).get("id"));
|
return Objects.toString(Json.object(branch.get("commit")).get("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ensureImage(String repository, String source) throws Exception {
|
void ensureImage(String repository, String source) throws Exception {
|
||||||
String ref = repository + ":" + commit;
|
String ref = repository + ":" + commit;
|
||||||
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
|
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
|
||||||
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
|
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
|
||||||
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
|
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
|
||||||
run("docker", "tag", source, ref); run("docker", "push", ref);
|
run("docker", "tag", source, ref);
|
||||||
} else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags");
|
run("docker", "push", ref);
|
||||||
|
} else
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Registry manifest lookup failed; authentication/network errors are not missing tags");
|
||||||
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
|
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
|
||||||
check(commit.equals(capture("docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)),
|
check(
|
||||||
|
commit.equals(capture(
|
||||||
|
"docker",
|
||||||
|
"image",
|
||||||
|
"inspect",
|
||||||
|
"--format",
|
||||||
|
"{{index .Config.Labels \"org.opencontainers.image.revision\"}}",
|
||||||
|
ref)),
|
||||||
"Existing image has an unexpected source revision");
|
"Existing image has an unexpected source revision");
|
||||||
String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
|
String digests =
|
||||||
|
capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
|
||||||
check(digests.contains(repository + "@sha256:"), "Missing registry digest");
|
check(digests.contains(repository + "@sha256:"), "Missing registry digest");
|
||||||
Files.writeString(artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n");
|
Files.writeString(
|
||||||
|
artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"),
|
||||||
|
digests + "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void closeDockerConfig() throws IOException {
|
synchronized void closeDockerConfig() throws IOException {
|
||||||
if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; }
|
if (isolatedConfig != null) {
|
||||||
|
deleteTree(isolatedConfig);
|
||||||
|
isolatedConfig = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void copyTree(Path source, Path destination) throws IOException {
|
static void copyTree(Path source, Path destination) throws IOException {
|
||||||
try (var paths = Files.walk(source)) {
|
try (var paths = Files.walk(source)) {
|
||||||
for (Path path : paths.toList()) {
|
for (Path path : paths.toList()) {
|
||||||
@@ -216,8 +427,11 @@ class Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void deleteTree(Path root) throws IOException {
|
static void deleteTree(Path root) throws IOException {
|
||||||
if (!Files.exists(root)) return;
|
if (!Files.exists(root)) return;
|
||||||
try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); }
|
try (var paths = Files.walk(root)) {
|
||||||
|
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
ci/Setup.java
220
ci/Setup.java
@@ -8,65 +8,100 @@ import java.time.Duration;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
class Setup {
|
class Setup {
|
||||||
static Path home() { return Path.of(System.getProperty("user.home")); }
|
static Path home() {
|
||||||
|
return Path.of(System.getProperty("user.home"));
|
||||||
|
}
|
||||||
|
|
||||||
static String required(Map<String, String> env, String key) {
|
static String required(Map<String, String> env, String key) {
|
||||||
String value = env.get(key);
|
String value = env.get(key);
|
||||||
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
|
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String secret(Map<String, String> env, String key) throws IOException {
|
static String secret(Map<String, String> env, String key) throws IOException {
|
||||||
Path path = Path.of(required(env, key));
|
Path path = Path.of(required(env, key));
|
||||||
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
|
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
|
||||||
if (permissions.stream().anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
|
if (permissions.stream()
|
||||||
|
.anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
|
||||||
throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)");
|
throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)");
|
||||||
String token = Files.readString(path).strip();
|
String token = Files.readString(path).strip();
|
||||||
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
|
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static class ApiFailure extends IOException {
|
static class ApiFailure extends IOException {
|
||||||
final int status;
|
final int status;
|
||||||
ApiFailure(int status) { super("Gitea HTTP " + status); this.status = status; }
|
|
||||||
|
ApiFailure(int status) {
|
||||||
|
super("Gitea HTTP " + status);
|
||||||
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Object api(String server, String token, String method, String path, Object body) throws Exception {
|
static Object api(String server, String token, String method, String path, Object body) throws Exception {
|
||||||
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
|
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path))
|
||||||
.timeout(Duration.ofSeconds(30)).header("Authorization", "token " + token)
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Authorization", "token " + token)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body)))
|
.method(
|
||||||
|
method,
|
||||||
|
body == null
|
||||||
|
? HttpRequest.BodyPublishers.noBody()
|
||||||
|
: HttpRequest.BodyPublishers.ofString(Json.write(body)))
|
||||||
.build();
|
.build();
|
||||||
try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
|
try (HttpClient client =
|
||||||
|
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
|
||||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) throw new ApiFailure(response.statusCode());
|
if (response.statusCode() < 200 || response.statusCode() >= 300)
|
||||||
|
throw new ApiFailure(response.statusCode());
|
||||||
return response.body().isBlank() ? null : Json.parse(response.body());
|
return response.body().isBlank() ? null : Json.parse(response.body());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void configureGitea(Map<String, String> env) throws Exception {
|
static void configureGitea(Map<String, String> env) throws Exception {
|
||||||
String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE");
|
String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE");
|
||||||
String user = required(env, "REGISTRY_USER"), platform = required(env, "TARGET_PLATFORM");
|
String user = required(env, "REGISTRY_USER"), platform = required(env, "TARGET_PLATFORM");
|
||||||
Pipeline.check(Set.of("linux/amd64","linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture");
|
Pipeline.check(
|
||||||
|
Set.of("linux/amd64", "linux/arm64").contains(platform),
|
||||||
|
"Set TARGET_PLATFORM from the verified Synology architecture");
|
||||||
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
|
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
|
||||||
api(server, admin, "PATCH", base, Map.of("has_actions", true));
|
api(server, admin, "PATCH", base, Map.of("has_actions", true));
|
||||||
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user));
|
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user));
|
||||||
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
|
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
|
||||||
String variable = base + "/actions/variables/TARGET_PLATFORM";
|
String variable = base + "/actions/variables/TARGET_PLATFORM";
|
||||||
boolean exists;
|
boolean exists;
|
||||||
try { api(server,admin,"GET",variable,null); exists = true; }
|
try {
|
||||||
catch (ApiFailure e) { if (e.status != 404) throw e; exists = false; }
|
api(server, admin, "GET", variable, null);
|
||||||
|
exists = true;
|
||||||
|
} catch (ApiFailure e) {
|
||||||
|
if (e.status != 404) throw e;
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
|
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
|
||||||
Map<String,Object> response = Json.object(api(server,admin,"POST",base + "/actions/runners/registration-token",null));
|
Map<String, Object> response =
|
||||||
|
Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null));
|
||||||
Path directory = home().resolve(".config/agiledancers-runner");
|
Path directory = home().resolve(".config/agiledancers-runner");
|
||||||
privateDirectory(directory);
|
privateDirectory(directory);
|
||||||
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
|
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
|
||||||
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
|
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void configureDocker(Path file) throws IOException {
|
static void configureDocker(Path file) throws IOException {
|
||||||
Map<String,Object> data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
|
Map<String, Object> data =
|
||||||
|
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
|
||||||
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
|
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
|
||||||
if (!(entry instanceof List<?>)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
|
if (!(entry instanceof List<?>))
|
||||||
|
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
|
||||||
List<Object> registries = new ArrayList<>((List<?>) entry);
|
List<Object> registries = new ArrayList<>((List<?>) entry);
|
||||||
if (registries.contains("192.168.2.28:3000")) { System.out.println("Internal registry already configured; no change"); return; }
|
if (registries.contains("192.168.2.28:3000")) {
|
||||||
registries.add("192.168.2.28:3000"); data.put("insecure-registries",registries);
|
System.out.println("Internal registry already configured; no change");
|
||||||
Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent);
|
return;
|
||||||
|
}
|
||||||
|
registries.add("192.168.2.28:3000");
|
||||||
|
data.put("insecure-registries", registries);
|
||||||
|
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
|
||||||
|
Files.createDirectories(parent);
|
||||||
if (Files.exists(file)) {
|
if (Files.exists(file)) {
|
||||||
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
|
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
|
||||||
if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
|
if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
@@ -76,86 +111,177 @@ class Setup {
|
|||||||
Files.writeString(temp, Json.write(data) + "\n");
|
Files.writeString(temp, Json.write(data) + "\n");
|
||||||
if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file));
|
if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file));
|
||||||
Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
} finally { Files.deleteIfExists(temp); }
|
} finally {
|
||||||
|
Files.deleteIfExists(temp);
|
||||||
|
}
|
||||||
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
|
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
|
||||||
}
|
}
|
||||||
static void installRunner(Path root, Map<String,String> env, Commands commands, boolean prepareOnly) throws Exception {
|
|
||||||
Pipeline.check(System.getProperty("os.name").equals("Linux") && Set.of("amd64","x86_64").contains(System.getProperty("os.arch")),
|
static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
|
||||||
|
throws Exception {
|
||||||
|
Pipeline.check(
|
||||||
|
System.getProperty("os.name").equals("Linux")
|
||||||
|
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
|
||||||
"Installer targets the existing Linux x86_64 mini-PC");
|
"Installer targets the existing Linux x86_64 mini-PC");
|
||||||
Path runnerDir = home().resolve(".local/share/agiledancers-runner"), configDir = home().resolve(".config/agiledancers-runner");
|
Path runnerDir = home().resolve(".local/share/agiledancers-runner"),
|
||||||
|
configDir = home().resolve(".config/agiledancers-runner");
|
||||||
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
|
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
|
||||||
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
|
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
|
||||||
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
|
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
|
||||||
String javaHome = System.getProperty("java.home");
|
String javaHome = System.getProperty("java.home");
|
||||||
String version = commands.execute(List.of("node","--version"),root,env,null,false).checked();
|
String version = commands.execute(List.of("node", "--version"), root, env, null, false)
|
||||||
|
.checked();
|
||||||
String[] parts = version.replaceFirst("^v", "").split("\\.");
|
String[] parts = version.replaceFirst("^v", "").split("\\.");
|
||||||
int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]);
|
int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]);
|
||||||
Pipeline.check((major == 22 && (minor > 22 || minor == 22 && patch >= 3)) || (major == 24 && minor >= 15) || major >= 26,
|
Pipeline.check(
|
||||||
|
(major == 22 && (minor > 22 || minor == 22 && patch >= 3))
|
||||||
|
|| (major == 24 && minor >= 15)
|
||||||
|
|| major >= 26,
|
||||||
"Supported Node version required");
|
"Supported Node version required");
|
||||||
Path runner = bin.resolve("act_runner");
|
Path runner = bin.resolve("act_runner");
|
||||||
if (!Files.isExecutable(runner)) {
|
if (!Files.isExecutable(runner)) {
|
||||||
URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64");
|
URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64");
|
||||||
byte[] bytes;
|
byte[] bytes;
|
||||||
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) {
|
try (HttpClient client = HttpClient.newBuilder()
|
||||||
var response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(2)).build(),HttpResponse.BodyHandlers.ofByteArray());
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
Pipeline.check(response.statusCode() == 200, "Runner download failed"); bytes = response.body();
|
.build()) {
|
||||||
|
var response = client.send(
|
||||||
|
HttpRequest.newBuilder(uri)
|
||||||
|
.timeout(Duration.ofMinutes(2))
|
||||||
|
.build(),
|
||||||
|
HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
Pipeline.check(response.statusCode() == 200, "Runner download failed");
|
||||||
|
bytes = response.body();
|
||||||
}
|
}
|
||||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
String hash = HexFormat.of()
|
||||||
Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch");
|
.formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||||
Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x"));
|
Pipeline.check(
|
||||||
|
hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),
|
||||||
|
"Runner checksum mismatch");
|
||||||
|
Files.write(runner, bytes);
|
||||||
|
Files.setPosixFilePermissions(runner, PosixFilePermissions.fromString("rwxr-xr-x"));
|
||||||
}
|
}
|
||||||
String actual = commands.execute(List.of(runner.toString(),"--version"),root,env,null,false).checked();
|
String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
|
||||||
|
.checked();
|
||||||
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
|
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
|
||||||
privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml")));
|
privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml")));
|
||||||
Files.copy(root.resolve("ci/runner/agiledancers-runner.service"),units.resolve("agiledancers-runner.service"),StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(
|
||||||
Files.copy(root.resolve("ci/runner/WaitForDocker.java"),runnerDir.resolve("WaitForDocker.java"),StandardCopyOption.REPLACE_EXISTING);
|
root.resolve("ci/runner/agiledancers-runner.service"),
|
||||||
|
units.resolve("agiledancers-runner.service"),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
Files.copy(
|
||||||
|
root.resolve("ci/runner/WaitForDocker.java"),
|
||||||
|
runnerDir.resolve("WaitForDocker.java"),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
String dockerHost = env.get("DOCKER_HOST");
|
String dockerHost = env.get("DOCKER_HOST");
|
||||||
if (dockerHost == null || dockerHost.isBlank()) dockerHost = commands.execute(List.of("docker","context","inspect","--format","{{.Endpoints.docker.Host}}"),root,env,null,false).checked();
|
if (dockerHost == null || dockerHost.isBlank())
|
||||||
String path = javaHome + "/bin:" + executable(env,"node").getParent() + ":" + executable(env,"mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
|
dockerHost = commands.execute(
|
||||||
privateWrite(configDir.resolve("environment"),"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n");
|
List.of("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"),
|
||||||
|
root,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
false)
|
||||||
|
.checked();
|
||||||
|
String path = javaHome + "/bin:" + executable(env, "node").getParent() + ":"
|
||||||
|
+ executable(env, "mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
|
||||||
|
privateWrite(
|
||||||
|
configDir.resolve("environment"),
|
||||||
|
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
|
||||||
|
+ "\n");
|
||||||
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
|
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
|
||||||
Path overrides = units.resolve("agiledancers-runner.service.d");
|
Path overrides = units.resolve("agiledancers-runner.service.d");
|
||||||
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
|
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
|
||||||
if (desktop) {
|
if (desktop) {
|
||||||
Files.createDirectories(overrides);
|
Files.createDirectories(overrides);
|
||||||
Files.copy(root.resolve("ci/runner/docker-desktop.conf"),desktopOverride,StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(
|
||||||
|
root.resolve("ci/runner/docker-desktop.conf"),
|
||||||
|
desktopOverride,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
} else {
|
} else {
|
||||||
Files.deleteIfExists(desktopOverride);
|
Files.deleteIfExists(desktopOverride);
|
||||||
}
|
}
|
||||||
commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).checked();
|
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
|
||||||
if (prepareOnly) { System.out.println("Runner installed; registration and startup still required."); return; }
|
.checked();
|
||||||
|
if (prepareOnly) {
|
||||||
|
System.out.println("Runner installed; registration and startup still required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
||||||
if (!Files.exists(runnerDir.resolve(".runner"))) {
|
if (!Files.exists(runnerDir.resolve(".runner"))) {
|
||||||
Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString()));
|
Path tokenPath = Path.of(env.getOrDefault(
|
||||||
|
"RUNNER_REGISTRATION_TOKEN_FILE",
|
||||||
|
configDir.resolve("registration-token").toString()));
|
||||||
Map<String, String> tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString());
|
Map<String, String> tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString());
|
||||||
String token = secret(tokenEnv, "RUNNER_TOKEN_FILE");
|
String token = secret(tokenEnv, "RUNNER_TOKEN_FILE");
|
||||||
commands.execute(List.of(runner.toString(),"register","--no-interactive","--instance","http://192.168.2.28:3000",
|
commands.execute(
|
||||||
"--name","agiledancers-mini-pc","--labels","agiledancers-mini-pc:host","--token",token),runnerDir,env,null,true).checked();
|
List.of(
|
||||||
|
runner.toString(),
|
||||||
|
"register",
|
||||||
|
"--no-interactive",
|
||||||
|
"--instance",
|
||||||
|
"http://192.168.2.28:3000",
|
||||||
|
"--name",
|
||||||
|
"agiledancers-mini-pc",
|
||||||
|
"--labels",
|
||||||
|
"agiledancers-mini-pc:host",
|
||||||
|
"--token",
|
||||||
|
token),
|
||||||
|
runnerDir,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
|
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
|
||||||
}
|
}
|
||||||
if (desktop) {
|
if (desktop) {
|
||||||
commands.execute(List.of("systemctl","--user","enable","--now","docker-desktop.service"),root,env,null,true).checked();
|
commands.execute(
|
||||||
|
List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"),
|
||||||
|
root,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
// Replace any previous default.target link with the desktop-login target.
|
// Replace any previous default.target link with the desktop-login target.
|
||||||
commands.execute(List.of("systemctl","--user","disable","agiledancers-runner.service"),root,env,null,true).checked();
|
commands.execute(
|
||||||
|
List.of("systemctl", "--user", "disable", "agiledancers-runner.service"),
|
||||||
|
root,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
}
|
}
|
||||||
commands.execute(List.of("systemctl","--user","enable","--now","agiledancers-runner.service"),root,env,null,true).checked();
|
commands.execute(
|
||||||
|
List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"),
|
||||||
|
root,
|
||||||
|
env,
|
||||||
|
null,
|
||||||
|
true)
|
||||||
|
.checked();
|
||||||
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
|
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static Path executable(Map<String, String> env, String name) {
|
static Path executable(Map<String, String> env, String name) {
|
||||||
for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) {
|
for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) {
|
||||||
Path path = Path.of(dir,name); if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
|
Path path = Path.of(dir, name);
|
||||||
|
if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Missing executable: " + name);
|
throw new IllegalArgumentException("Missing executable: " + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String quoted(String value) {
|
static String quoted(String value) {
|
||||||
Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value");
|
Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value");
|
||||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
static void privateDirectory(Path path) throws IOException {
|
static void privateDirectory(Path path) throws IOException {
|
||||||
Files.createDirectories(path); Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rwx------"));
|
Files.createDirectories(path);
|
||||||
|
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void privateWrite(Path path, String text) throws IOException {
|
static void privateWrite(Path path, String text) throws IOException {
|
||||||
if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
if (!Files.exists(path))
|
||||||
Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text);
|
Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||||
|
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
|
||||||
|
Files.writeString(path, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user