Compare commits
10 Commits
f281200f47
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d6592761 | |||
| 60496de8bb | |||
| 525e9f9335 | |||
| c9db528756 | |||
| b1544357bf | |||
| 3a5203eb47 | |||
| a7f7730ea0 | |||
| c965f9b41d | |||
| 8fc430537b | |||
| fc307de64e |
68
.gitea/workflows/deploy.yaml
Normal file
68
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'Release mode — deployment can erase data until S16 is implemented'
|
||||
required: true
|
||||
type: choice
|
||||
default: new
|
||||
options:
|
||||
- new
|
||||
- retry
|
||||
increment:
|
||||
description: 'New release only: semantic version increment (first release is v1.0.0)'
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
version:
|
||||
description: 'Retry only: existing version tag, e.g. v1.2.3; leave empty for new release'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Gitea 1.25 ignores concurrency. The helper acquires a NAS-wide mkdir lock.
|
||||
jobs:
|
||||
deploy:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: agiledancers-mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
CI_REPOSITORY: ${{ gitea.repository }}
|
||||
CI_COMMIT: ${{ gitea.sha }}
|
||||
CI_REF: ${{ gitea.ref }}
|
||||
CI_EVENT: ${{ gitea.event_name }}
|
||||
CI_RUN_ID: ${{ gitea.run_id }}
|
||||
steps:
|
||||
- name: Check out trusted main workflow revision
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Identify workflow attempt
|
||||
run: java ci/Ci.java attempt
|
||||
- name: Release verified images — data can be lost until S16
|
||||
env:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_INCREMENT: ${{ inputs.increment }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
|
||||
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
|
||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: java ci/Ci.java deploy
|
||||
- name: Retain deployment diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: deployment-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
|
||||
path: ci/artifacts/deployment/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
42
.gitea/workflows/rollback.yaml
Normal file
42
.gitea/workflows/rollback.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Rollback production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: agiledancers-mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
CI_REPOSITORY: ${{ gitea.repository }}
|
||||
CI_COMMIT: ${{ gitea.sha }}
|
||||
CI_REF: ${{ gitea.ref }}
|
||||
CI_EVENT: ${{ gitea.event_name }}
|
||||
CI_RUN_ID: ${{ gitea.run_id }}
|
||||
steps:
|
||||
- name: Check out trusted main workflow revision
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Identify workflow attempt
|
||||
run: java ci/Ci.java attempt
|
||||
- name: Restore previous successful release — data can be lost until S16
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
|
||||
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
|
||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: java ci/Ci.java rollback
|
||||
- name: Retain deployment diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: rollback-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
|
||||
path: ci/artifacts/deployment/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
47
README.md
47
README.md
@@ -154,23 +154,35 @@ dancers; deleting a school also deletes its dancers, groups and shows.
|
||||
|
||||
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
|
||||
with unique date/type combinations. The displayed date range comes from these
|
||||
dayparts. Each show stores `appearances` for every group type. Omitting counts
|
||||
initializes defaults on creation or the initial daypart configuration; later
|
||||
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
|
||||
shows remain unscheduled and saved counts are retained.
|
||||
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
|
||||
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
|
||||
dayparts; preschool groups once; showcases every daypart. See
|
||||
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
|
||||
coach exceptions, ordering and optimization rules.
|
||||
|
||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to
|
||||
assign every current school group to its required number of distinct dayparts,
|
||||
minimizing the busiest/quietest appearance gap. It replaces assignments only on
|
||||
a valid, current result. The response distinguishes optimal balance from a valid
|
||||
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
|
||||
also reject the result with `409`. Failed runs preserve existing assignments.
|
||||
Scheduling runs synchronously with a 30-minute solver limit; development and
|
||||
production proxies allow 31 minutes. The UI warns before edits that may invalidate
|
||||
existing schedules and offers explicit scheduling again afterward.
|
||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
|
||||
draft using OR-Tools with a total thirty-minute time limit. A feasible result is
|
||||
accepted without claiming optimality. Concurrent runs and stale results return
|
||||
`409`; failures retain existing assignments. Development and production proxies
|
||||
allow 31 minutes.
|
||||
|
||||
School JSON exports use format version **2**, including dated dayparts, assignments,
|
||||
and all appearance counts. Version 1 files are intentionally unsupported.
|
||||
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
||||
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
||||
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
|
||||
checks every hard rule before marking a schedule final. Show responses include
|
||||
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
|
||||
status. The UI supports ordering and moving performances, and displays violations.
|
||||
|
||||
`GET .../coaches` lists coach registrations. `POST .../coaches` with
|
||||
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
|
||||
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
|
||||
removes the identity link. The dancers screen supports maintaining these links.
|
||||
|
||||
School JSON exports use format version **3**, including preferredDayParts, schedule status and
|
||||
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
|
||||
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
|
||||
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
|
||||
The REST `appearances` input is deprecated; use `preferredDayParts`.
|
||||
|
||||
Run the HTTP integration tests with:
|
||||
|
||||
@@ -254,6 +266,11 @@ Only successful `main` runs publish images to the Synology registry. See
|
||||
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
|
||||
for the current operational status.
|
||||
|
||||
Production releases and rollback use separate manual Gitea workflows. See the
|
||||
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
|
||||
semantic version tags, release/retry/rollback and the accepted data-loss limitation
|
||||
until S16, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
|
||||
|
||||
### Java formatting (S8)
|
||||
|
||||
Maven is the authoritative formatting path. The root build runs the actual
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@@ -13,4 +14,7 @@ import lombok.ToString;
|
||||
public class Coach extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final String name;
|
||||
|
||||
@Setter
|
||||
private Dancer dancer;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
|
||||
@Setter
|
||||
private String name;
|
||||
|
||||
@Setter
|
||||
private Integer preferredDayParts;
|
||||
|
||||
@Setter
|
||||
private boolean finalized;
|
||||
|
||||
@Setter
|
||||
private Map<DanceGroupType, Integer> appearances;
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public record GroupRequirement(long groupId, int appearances) {}
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public record GroupRequirement(
|
||||
long groupId,
|
||||
int appearances,
|
||||
boolean showcase,
|
||||
Set<Long> dancerIds,
|
||||
Set<DanceGroupStyle> styles,
|
||||
double averageAge) {
|
||||
public GroupRequirement {
|
||||
dancerIds = Set.copyOf(dancerIds);
|
||||
styles = Set.copyOf(styles);
|
||||
}
|
||||
|
||||
public GroupRequirement(final long groupId, final int appearances) {
|
||||
this(groupId, appearances, false, Set.of(), Set.of(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Shared hard-rule validation for solver results and manually edited schedules. */
|
||||
public final class ScheduleRules {
|
||||
private ScheduleRules() {}
|
||||
|
||||
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
|
||||
final List<String> errors = new ArrayList<>();
|
||||
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
|
||||
errors.add("Configure dayparts and preferredDayParts between 1 and the number of dayparts.");
|
||||
}
|
||||
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
errors.add("Assignments must contain one list for every daypart.");
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
validatePart(parts.get(p), groups, p, errors);
|
||||
}
|
||||
appearances(input, parts, errors);
|
||||
attendance(input, parts, errors);
|
||||
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||
if (max - min > 2) {
|
||||
errors.add("Daypart performance counts differ by more than two.");
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
private static void validatePart(
|
||||
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
|
||||
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
|
||||
return;
|
||||
}
|
||||
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
|
||||
showcases(ordered, part, errors);
|
||||
gaps(ordered, part, errors);
|
||||
}
|
||||
|
||||
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final long count = groups.stream().filter(GroupRequirement::showcase).count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
final boolean first = groups.getFirst().showcase();
|
||||
final boolean last = groups.getLast().showcase();
|
||||
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
|
||||
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final Map<Long, Integer> last = new HashMap<>();
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
for (final long dancer : groups.get(i).dancerIds()) {
|
||||
final Integer previous = last.put(dancer, i);
|
||||
if (previous != null && i - previous < 3) {
|
||||
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
|
||||
+ " needs two intervening performances before group "
|
||||
+ groups.get(i).groupId() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appearances(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
if (parts.stream()
|
||||
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
|
||||
.count()
|
||||
!= group.appearances()) {
|
||||
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void attendance(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
final Set<Long> exempt = new HashSet<>(input.coachDancerIds());
|
||||
input.groups().stream()
|
||||
.filter(GroupRequirement::showcase)
|
||||
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
|
||||
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
recordAttendance(group, parts, attendance);
|
||||
}
|
||||
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
|
||||
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
|
||||
errors.add(
|
||||
"Dancer " + dancer + " exceeds preferredDayParts=" + input.preferredDayParts() + " dayparts.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void recordAttendance(
|
||||
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
if (parts.get(p).contains(group.groupId())) {
|
||||
for (final long dancer : group.dancerIds()) {
|
||||
attendance
|
||||
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||
.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
|
||||
/**
|
||||
* Immutable solver snapshot.
|
||||
*
|
||||
* @param coachDancerIds dancer registration IDs linked to coaches, used for participation exemptions
|
||||
*/
|
||||
public record SchedulingInput(
|
||||
long showId,
|
||||
long revision,
|
||||
int dayParts,
|
||||
List<GroupRequirement> groups,
|
||||
int preferredDayParts,
|
||||
Set<Long> coachDancerIds) {
|
||||
public SchedulingInput {
|
||||
groups = List.copyOf(groups);
|
||||
coachDancerIds = Set.copyOf(coachDancerIds);
|
||||
}
|
||||
|
||||
public SchedulingInput(
|
||||
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
|
||||
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public final class VarietyScore {
|
||||
private VarietyScore() {}
|
||||
|
||||
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
|
||||
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
|
||||
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
|
||||
union.addAll(b.styles());
|
||||
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
|
||||
shared.retainAll(b.styles());
|
||||
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
|
||||
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
|
||||
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||
import nl.agilecc.agiledancers.service.CoachLinks;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/coaches")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class CoachResource {
|
||||
private @Inject CoachLinks links;
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<CoachView> list() {
|
||||
return links.list(schoolId).stream().map(CoachView::of).toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
public CoachView create(final @NotNull @Valid CoachRequest request) {
|
||||
return CoachView.of(links.link(schoolId, null, request.dancerId()));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
|
||||
return CoachView.of(links.link(schoolId, id, request.dancerId()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}/dancer")
|
||||
public void unlink(final @PathParam("id") long id) {
|
||||
links.unlink(schoolId, id);
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/shows")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@@ -34,33 +36,60 @@ public class ShowResource {
|
||||
|
||||
private @Inject SchedulingService scheduling;
|
||||
|
||||
private @Inject ShowSchedules schedules;
|
||||
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<ShowView> list() {
|
||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
||||
return service.shows(schoolId).stream().map(this::view).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public ShowView get(final @PathParam("id") long id) {
|
||||
return ShowView.of(service.show(schoolId, id));
|
||||
return view(service.show(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
||||
final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
||||
final Show entity = schedules.configure(schoolId, null, settings(request));
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(ShowView.of(entity))
|
||||
.entity(view(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
|
||||
return view(schedules.configure(schoolId, id, settings(request)));
|
||||
}
|
||||
|
||||
private ShowView view(final Show show) {
|
||||
return ShowView.of(show, schedules.violations(schoolId, show.getId()));
|
||||
}
|
||||
|
||||
private static Show settings(final ShowRequest request) {
|
||||
return Show.builder()
|
||||
.name(request.name())
|
||||
.preferredDayParts(request.preferredDayParts())
|
||||
.dayParts(parts(request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/assignments")
|
||||
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
|
||||
return view(schedules.saveDraft(schoolId, id, request.assignments()));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/finalize")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public ShowView finalizeSchedule(final @PathParam("id") long id) {
|
||||
return view(schedules.finalizeSchedule(schoolId, id));
|
||||
}
|
||||
|
||||
private static List<DayPart> parts(final ShowRequest request) {
|
||||
@@ -79,7 +108,7 @@ public class ShowResource {
|
||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
||||
return new SchedulingView(
|
||||
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
|
||||
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
|
||||
}
|
||||
|
||||
@DELETE
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CoachRequest(@NotNull Long dancerId) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
|
||||
public record CoachView(long id, String name, Long dancerId) {
|
||||
public static CoachView of(final Coach coach) {
|
||||
return new CoachView(
|
||||
coach.getId(),
|
||||
coach.getName(),
|
||||
coach.getDancer() == null ? null : coach.getDancer().getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
|
||||
public ScheduleRequest {
|
||||
assignments = assignments == null
|
||||
? null
|
||||
: assignments.stream()
|
||||
.map((final List<Long> p) ->
|
||||
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
public record ShowRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances) {
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
Integer preferredDayParts) {
|
||||
public ShowRequest {
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
|
||||
@@ -10,13 +10,25 @@ import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
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,
|
||||
int preferredDayParts,
|
||||
boolean finalized,
|
||||
List<String> violations) {
|
||||
public ShowView {
|
||||
violations = List.copyOf(violations);
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
}
|
||||
|
||||
public static ShowView of(final Show show) {
|
||||
return of(show, List.of());
|
||||
}
|
||||
|
||||
public static ShowView of(final Show show, final List<String> violations) {
|
||||
return new ShowView(
|
||||
show.getId(),
|
||||
show.getName(),
|
||||
@@ -28,6 +40,9 @@ public record ShowView(long id, String name, List<DayPartView> dayParts, Map<Dan
|
||||
.map(DanceGroup::getId)
|
||||
.toList()))
|
||||
.toList(),
|
||||
show.getAppearances());
|
||||
show.getAppearances(),
|
||||
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||
show.isFinalized(),
|
||||
violations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
|
||||
@Singleton
|
||||
public class CoachLinks {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public List<Coach> list(final long schoolId) {
|
||||
return List.copyOf(schools.school(schoolId).getCoaches());
|
||||
}
|
||||
|
||||
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
final Dancer dancer = schools.dancer(schoolId, dancerId);
|
||||
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
|
||||
coach.setDancer(dancer);
|
||||
school.getShows().forEach(schools::touch);
|
||||
return coach;
|
||||
}
|
||||
}
|
||||
|
||||
private static Coach create(final DanceSchool school, final Dancer dancer) {
|
||||
final Coach coach = Coach.builder().name(dancer.getName()).build();
|
||||
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
|
||||
school.addCoach(coach);
|
||||
return coach;
|
||||
}
|
||||
|
||||
private static Coach required(final DanceSchool school, final long id) {
|
||||
return school.getCoaches().stream()
|
||||
.filter((final Coach c) -> c.getId() == id)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
|
||||
}
|
||||
|
||||
public void unlink(final long schoolId, final long id) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
required(school, id).setDancer(null);
|
||||
school.getShows().forEach(schools::touch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public final class SchedulingInputs {
|
||||
private SchedulingInputs() {}
|
||||
|
||||
public static int preferred(final Show show) {
|
||||
if (show.getPreferredDayParts() != null) {
|
||||
return show.getPreferredDayParts();
|
||||
}
|
||||
return (show.getDayParts().size() == 1 ? 1 : 2);
|
||||
}
|
||||
|
||||
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
|
||||
final int preferredDayParts = preferred(show);
|
||||
final LocalDate date = show.getDayParts().stream()
|
||||
.map(DayPart::getDate)
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(LocalDate.now());
|
||||
final List<GroupRequirement> groups = school.getDanceGroups().stream()
|
||||
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
|
||||
.map((final DanceGroup g) -> requirement(g, show, date))
|
||||
.toList();
|
||||
final Set<Long> coaches = school.getCoaches().stream()
|
||||
.map(Coach::getDancer)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Dancer::getId)
|
||||
.collect(Collectors.toSet());
|
||||
return new SchedulingInput(
|
||||
show.getId(), revision, show.getDayParts().size(), groups, preferredDayParts, coaches);
|
||||
}
|
||||
|
||||
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
|
||||
final int count = switch (group.getType()) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE -> show.getDayParts().size();
|
||||
default -> preferred(show);
|
||||
};
|
||||
final double age = group.getDancers().stream()
|
||||
.mapToInt((final Dancer d) ->
|
||||
Period.between(d.getBirthDate(), date).getYears())
|
||||
.average()
|
||||
.orElse(0);
|
||||
return new GroupRequirement(
|
||||
group.getId(),
|
||||
count,
|
||||
group.getType() == DanceGroupType.SHOW_CASE,
|
||||
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
|
||||
group.getStyles(),
|
||||
age);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
@@ -41,7 +41,8 @@ public class SchoolManagementService {
|
||||
private final Map<Long, Long> revisions = new HashMap<>();
|
||||
private final Set<Long> running = new HashSet<>();
|
||||
|
||||
private void touch(final Show show) {
|
||||
public synchronized void touch(final Show show) {
|
||||
show.setFinalized(false);
|
||||
revisions.merge(show.getId(), 1L, Long::sum);
|
||||
}
|
||||
|
||||
@@ -58,16 +59,12 @@ public class SchoolManagementService {
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Scheduling requires at least one school group and one daypart.");
|
||||
}
|
||||
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
||||
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
|
||||
}
|
||||
running.add(showId);
|
||||
return new SchedulingInput(
|
||||
showId,
|
||||
revisions.getOrDefault(showId, 0L),
|
||||
show.getDayParts().size(),
|
||||
current.stream()
|
||||
.map((final DanceGroup g) -> new GroupRequirement(
|
||||
g.getId(), show.getAppearances().get(g.getType())))
|
||||
.toList());
|
||||
return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
|
||||
}
|
||||
|
||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||
@@ -97,6 +94,7 @@ public class SchoolManagementService {
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
touch(show);
|
||||
}
|
||||
|
||||
private Map<Long, DanceGroup> validateAssignments(
|
||||
@@ -111,29 +109,13 @@ public class SchoolManagementService {
|
||||
}
|
||||
final Map<Long, DanceGroup> current = new HashMap<>();
|
||||
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
||||
validateParts(result.assignments(), current.keySet());
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
final long id = group.groupId();
|
||||
if (result.assignments().stream()
|
||||
.filter((final List<Long> p) -> p.contains(id))
|
||||
.count()
|
||||
!= group.appearances()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance.");
|
||||
}
|
||||
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static void validateParts(final List<List<Long>> assignments, final Set<Long> groupIds) {
|
||||
for (final List<Long> part : assignments) {
|
||||
if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases the run slot after success or failure, without changing saved assignments. */
|
||||
public synchronized void endScheduling(final long showId) {
|
||||
running.remove(showId);
|
||||
@@ -186,6 +168,7 @@ public class SchoolManagementService {
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
dancer.setName(name);
|
||||
dancer.setBirthDate(birthDate);
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
@@ -193,6 +176,10 @@ public class SchoolManagementService {
|
||||
final DanceSchool school = school(schoolId);
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
||||
school.getCoaches().stream()
|
||||
.filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer)
|
||||
.forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null));
|
||||
school.getShows().forEach(this::touch);
|
||||
school.removeDancer(dancer);
|
||||
dancers.delete(dancerId);
|
||||
}
|
||||
@@ -224,7 +211,7 @@ public class SchoolManagementService {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(details.startAge(), details.endAge());
|
||||
group.setCategory(details.category());
|
||||
if (group.getType() != details.type()) {
|
||||
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
group.setType(details.type());
|
||||
@@ -248,11 +235,13 @@ public class SchoolManagementService {
|
||||
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.addDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.removeDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized List<Show> shows(final long schoolId) {
|
||||
@@ -273,6 +262,7 @@ public class SchoolManagementService {
|
||||
final Show show = shows.add(Show.builder()
|
||||
.name(name)
|
||||
.dayParts(List.copyOf(parts))
|
||||
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
||||
.appearances(Map.copyOf(counts))
|
||||
.build());
|
||||
school.addShow(show);
|
||||
@@ -292,18 +282,7 @@ public class SchoolManagementService {
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
final boolean changed =
|
||||
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||
final List<DayPart> retained = parts.stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream()
|
||||
.filter((final DayPart old) ->
|
||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst()
|
||||
.map(DayPart::getDanceGroups)
|
||||
.orElse(List.of()))
|
||||
.build())
|
||||
.toList();
|
||||
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
||||
show.setName(name);
|
||||
show.setDayParts(retained);
|
||||
show.setAppearances(Map.copyOf(counts));
|
||||
@@ -413,6 +392,7 @@ public class SchoolManagementService {
|
||||
final Show rebuilt = Show.builder()
|
||||
.name(show.getName())
|
||||
.appearances(show.getAppearances())
|
||||
.preferredDayParts(show.getPreferredDayParts())
|
||||
.dayParts(show.getDayParts().stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
|
||||
@@ -5,11 +5,27 @@ import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public final class ShowConfiguration {
|
||||
private ShowConfiguration() {}
|
||||
|
||||
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream()
|
||||
.filter((final DayPart old) ->
|
||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst()
|
||||
.map(DayPart::getDanceGroups)
|
||||
.orElse(List.of()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
||||
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||
if (n > 0) {
|
||||
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
|
||||
type,
|
||||
switch (type) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
||||
case SHOW_CASE -> n;
|
||||
default -> Math.min(2, n);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +59,7 @@ public final class ShowConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateParts(final List<DayPart> parts) {
|
||||
public static void validateParts(final List<DayPart> parts) {
|
||||
if (parts == null
|
||||
|| parts.size() > 6
|
||||
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
|
||||
@Singleton
|
||||
public class ShowSchedules {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public ShowSchedules() {}
|
||||
|
||||
public ShowSchedules(final SchoolManagementService schools) {
|
||||
this.schools = schools;
|
||||
}
|
||||
|
||||
/** Retains assignments by date/type; removing dayparts may retain an invalid preferredDayParts in the draft. */
|
||||
public Show configure(final long schoolId, final Long showId, final Show settings) {
|
||||
synchronized (schools) {
|
||||
ShowConfiguration.validateParts(settings.getDayParts());
|
||||
final Show existing = showId == null ? null : schools.show(schoolId, showId);
|
||||
final int preferredDayParts = settings.getPreferredDayParts() == null
|
||||
? (existing == null
|
||||
? (settings.getDayParts().size() == 1 ? 1 : 2)
|
||||
: SchedulingInputs.preferred(existing))
|
||||
: settings.getPreferredDayParts();
|
||||
validatePreferredDayParts(
|
||||
preferredDayParts, existing, settings.getDayParts().size());
|
||||
final Show show = existing == null
|
||||
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
|
||||
: existing;
|
||||
final boolean changed = preferredDayParts != SchedulingInputs.preferred(show)
|
||||
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
|
||||
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
|
||||
show.setName(settings.getName());
|
||||
show.setPreferredDayParts(preferredDayParts);
|
||||
if (changed) {
|
||||
schools.touch(show);
|
||||
}
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> keys(final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void validatePreferredDayParts(final int preferredDayParts, final Show existing, final int parts) {
|
||||
if (preferredDayParts < 1
|
||||
|| preferredDayParts > 6
|
||||
|| (parts > 0
|
||||
&& preferredDayParts > parts
|
||||
&& (existing == null || preferredDayParts != SchedulingInputs.preferred(existing)))) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"preferredDayParts must be between 1 and the number of dayparts. Retained preferredDayParts must"
|
||||
+ " be corrected before scheduling.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
|
||||
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
if (assignments == null || assignments.size() != show.getDayParts().size()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
|
||||
}
|
||||
final List<DayPart> replacement = new ArrayList<>();
|
||||
for (int p = 0; p < assignments.size(); p++) {
|
||||
final DayPart old = show.getDayParts().get(p);
|
||||
final List<Long> ids = assignments.get(p);
|
||||
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
|
||||
}
|
||||
final List<DanceGroup> groups = ids.stream()
|
||||
.map((final Long id) -> schools.group(schoolId, id))
|
||||
.toList();
|
||||
replacement.add(DayPart.builder()
|
||||
.date(old.getDate())
|
||||
.type(old.getType())
|
||||
.danceGroups(groups)
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
schools.touch(show);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks a schedule final only after checking all hard rules against current school data. */
|
||||
public Show finalizeSchedule(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final List<String> errors = violations(schoolId, showId);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
|
||||
}
|
||||
show.setFinalized(true);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> violations(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
|
||||
return ScheduleRules.violations(
|
||||
input,
|
||||
show.getDayParts().stream()
|
||||
.map((final DayPart p) -> p.getDanceGroups().stream()
|
||||
.map(DanceGroup::getId)
|
||||
.toList())
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,21 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {
|
||||
public record Performance(
|
||||
String id,
|
||||
String name,
|
||||
List<Part> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
Integer preferredDayParts,
|
||||
boolean finalized) {
|
||||
public Performance(
|
||||
final String id,
|
||||
final String name,
|
||||
final List<Part> dayParts,
|
||||
final Map<DanceGroupType, Integer> appearances) {
|
||||
this(id, name, dayParts, appearances, null, false);
|
||||
}
|
||||
|
||||
public Performance {
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
|
||||
@@ -23,7 +23,7 @@ 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 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */
|
||||
public record SchoolSnapshot(
|
||||
Integer formatVersion,
|
||||
UUID key,
|
||||
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
|
||||
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||
final Map<Show, String> shows = ids(s.getShows());
|
||||
return new SchoolSnapshot(
|
||||
2,
|
||||
3,
|
||||
s.getKey(),
|
||||
s.getName(),
|
||||
s.getDancers().stream()
|
||||
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
||||
.toList(),
|
||||
s.getCoaches().stream()
|
||||
.map((final Coach c) -> new Trainer(coaches.get(c), c.getName()))
|
||||
.map((final Coach c) -> new Trainer(
|
||||
coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer())))
|
||||
.toList(),
|
||||
s.getDanceGroups().stream()
|
||||
.map((final DanceGroup g) -> new Group(
|
||||
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
|
||||
.map(groups::get)
|
||||
.toList()))
|
||||
.toList(),
|
||||
show.getAppearances()))
|
||||
show.getAppearances(),
|
||||
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||
show.isFinalized()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
|
||||
|
||||
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
||||
public DanceSchool restore() {
|
||||
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(
|
||||
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
|
||||
"Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(key != null, "key ontbreekt");
|
||||
text(name, "schoolnaam");
|
||||
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||
final Map<String, Dancer> dancerMap = restoreDancers(school);
|
||||
final Map<String, Coach> coachMap = restoreCoaches(school);
|
||||
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
|
||||
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
|
||||
restoreShows(school, groupMap);
|
||||
return school;
|
||||
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
|
||||
return dancerMap;
|
||||
}
|
||||
|
||||
private Map<String, Coach> restoreCoaches(final DanceSchool school) {
|
||||
private Map<String, Coach> restoreCoaches(final DanceSchool school, final Map<String, Dancer> dancerMap) {
|
||||
final Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||
for (final Trainer c : list(coaches, "coaches")) {
|
||||
check(c != null, "Lege coach");
|
||||
text(c.name(), "coachnaam");
|
||||
final Coach coach = Coach.builder().name(c.name()).build();
|
||||
final Coach coach = Coach.builder()
|
||||
.name(c.name())
|
||||
.dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser"))
|
||||
.build();
|
||||
coach.setId((long) coachMap.size());
|
||||
put(coachMap, c.id(), coach);
|
||||
school.addCoach(coach);
|
||||
@@ -190,17 +198,25 @@ public record SchoolSnapshot(
|
||||
parts.add(DayPart.builder()
|
||||
.date(part.date())
|
||||
.type(part.type())
|
||||
.danceGroups(unique(part.groupIds(), "groupIds").stream()
|
||||
.danceGroups(list(part.groupIds(), "groupIds").stream()
|
||||
.map((final String id) ->
|
||||
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
||||
.toList())
|
||||
.build());
|
||||
}
|
||||
ShowConfiguration.validate(parts, show.appearances());
|
||||
ShowConfiguration.validateParts(parts);
|
||||
final int preferredDayParts =
|
||||
show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts();
|
||||
check(preferredDayParts >= 1 && preferredDayParts <= 6, "preferredDayParts moet tussen 1 en 6 liggen");
|
||||
final Show entity = Show.builder()
|
||||
.name(show.name())
|
||||
.dayParts(parts)
|
||||
.appearances(Map.copyOf(show.appearances()))
|
||||
.appearances(
|
||||
show.appearances() == null
|
||||
? ShowConfiguration.defaults(parts.size())
|
||||
: Map.copyOf(show.appearances()))
|
||||
.preferredDayParts(preferredDayParts)
|
||||
.finalized(false)
|
||||
.build();
|
||||
entity.setId((long) showIds.size());
|
||||
school.addShow(entity);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
public record Trainer(String id, String name) {}
|
||||
public record Trainer(String id, String name, String dancerId) {
|
||||
public Trainer(final String id, final String name) {
|
||||
this(id, name, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.Loader;
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.CpSolver;
|
||||
import com.google.ortools.sat.CpSolverStatus;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
@@ -18,50 +15,51 @@ import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
@Singleton
|
||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
private final double timeLimitSeconds;
|
||||
|
||||
public OrToolsSchedulingEngine() {
|
||||
this(1800);
|
||||
}
|
||||
|
||||
/** Allows callers to impose a shorter run budget, for example in automated tests. */
|
||||
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
|
||||
this.timeLimitSeconds = timeLimitSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchedulingResult solve(final SchedulingInput input) {
|
||||
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
|
||||
Loader.loadNativeLibraries();
|
||||
final CpModel model = new CpModel();
|
||||
final BoolVar[][] assigned = assignments(model, input);
|
||||
balance(model, assigned, input.dayParts());
|
||||
final OrderedAssignments vars = OrderedAssignments.create(model, input);
|
||||
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
|
||||
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
|
||||
final CpSolver solver = new CpSolver();
|
||||
solver.getParameters().setMaxTimeInSeconds(1800);
|
||||
// Quarkus owns process shutdown; do not install a native SIGINT handler.
|
||||
solver.getParameters().setCatchSigintSignal(false);
|
||||
// Single-worker search with a fixed seed makes identical inputs repeatable.
|
||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
||||
SchedulingResult best = null;
|
||||
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
|
||||
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
||||
if (remaining <= 0) {
|
||||
if (best != null) {
|
||||
return new SchedulingResult(false, best.assignments());
|
||||
}
|
||||
requireSolution(CpSolverStatus.UNKNOWN);
|
||||
}
|
||||
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
||||
model.minimize(objective);
|
||||
final CpSolverStatus status = solver.solve(model);
|
||||
if (status == CpSolverStatus.UNKNOWN && best != null) {
|
||||
return new SchedulingResult(false, best.assignments());
|
||||
}
|
||||
requireSolution(status);
|
||||
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver));
|
||||
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
|
||||
if (status != CpSolverStatus.OPTIMAL) {
|
||||
return best;
|
||||
}
|
||||
|
||||
private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) {
|
||||
final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()];
|
||||
for (int g = 0; g < assigned.length; g++) {
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
|
||||
model.addEquality(objective, Math.round(solver.objectiveValue()));
|
||||
}
|
||||
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) {
|
||||
final int groups = assigned.length;
|
||||
final IntVar[] loads = new IntVar[dayParts];
|
||||
for (int p = 0; p < dayParts; p++) {
|
||||
final BoolVar[] column = new BoolVar[groups];
|
||||
for (int g = 0; g < groups; g++) {
|
||||
column[g] = assigned[g][p];
|
||||
}
|
||||
loads[p] = model.newIntVar(0, groups, "load" + p);
|
||||
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||
}
|
||||
final IntVar max = model.newIntVar(0, groups, "max");
|
||||
final IntVar min = model.newIntVar(0, groups, "min");
|
||||
model.addMaxEquality(max, loads);
|
||||
model.addMinEquality(min, loads);
|
||||
model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
|
||||
return best;
|
||||
}
|
||||
|
||||
private static void requireSolution(final CpSolverStatus status) {
|
||||
@@ -77,19 +75,4 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<List<Long>> readAssignments(
|
||||
final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) {
|
||||
final List<List<Long>> parts = new ArrayList<>();
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final List<Long> ids = new ArrayList<>();
|
||||
for (int g = 0; g < assigned.length; g++) {
|
||||
if (solver.booleanValue(assigned[g][p])) {
|
||||
ids.add(input.groups().get(g).groupId());
|
||||
}
|
||||
}
|
||||
parts.add(List.copyOf(ids));
|
||||
}
|
||||
return List.copyOf(parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
|
||||
/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */
|
||||
public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) {
|
||||
public static OrderedAssignments create(final CpModel model, final SchedulingInput input) {
|
||||
final int n = input.groups().size();
|
||||
final BoolVar[][] present = new BoolVar[n][input.dayParts()];
|
||||
final IntVar[][] positions = new IntVar[n][input.dayParts()];
|
||||
for (int g = 0; g < n; g++) {
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
present[g][p] = model.newBoolVar("present" + g + ":" + p);
|
||||
positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p);
|
||||
model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]);
|
||||
model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not());
|
||||
}
|
||||
model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances());
|
||||
}
|
||||
final IntVar[] loads = loads(model, present, positions, input.dayParts());
|
||||
final IntVar max = model.newIntVar(0, n, "max");
|
||||
final IntVar min = model.newIntVar(0, n, "min");
|
||||
final IntVar spread = model.newIntVar(0, 2, "spread");
|
||||
model.addMaxEquality(max, loads);
|
||||
model.addMinEquality(min, loads);
|
||||
model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
|
||||
final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread);
|
||||
result.boundaries(model, input);
|
||||
result.changingTime(model, input);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IntVar[] loads(
|
||||
final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) {
|
||||
final IntVar[] loads = new IntVar[parts];
|
||||
for (int p = 0; p < parts; p++) {
|
||||
final BoolVar[] column = new BoolVar[present.length];
|
||||
final IntVar[] order = new IntVar[present.length];
|
||||
for (int g = 0; g < present.length; g++) {
|
||||
column[g] = present[g][p];
|
||||
order[g] = positions[g][p];
|
||||
}
|
||||
loads[p] = model.newIntVar(0, present.length, "load" + p);
|
||||
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||
model.addAllDifferent(order);
|
||||
for (int g = 0; g < present.length; g++) {
|
||||
model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]);
|
||||
}
|
||||
}
|
||||
return loads;
|
||||
}
|
||||
|
||||
private void boundaries(final CpModel model, final SchedulingInput input) {
|
||||
final List<Integer> showcases = IntStream.range(0, input.groups().size())
|
||||
.filter((final int g) -> input.groups().get(g).showcase())
|
||||
.boxed()
|
||||
.toList();
|
||||
if (showcases.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final List<BoolVar> first = new ArrayList<>();
|
||||
final List<BoolVar> last = new ArrayList<>();
|
||||
for (final int g : showcases) {
|
||||
first.add(equals(model, position[g][p], LinearExpr.constant(0)));
|
||||
last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1)));
|
||||
}
|
||||
if (showcases.size() == 1) {
|
||||
model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()});
|
||||
} else {
|
||||
model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1);
|
||||
model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) {
|
||||
final BoolVar equal = model.newBoolVar("boundary");
|
||||
model.addEquality(position, value).onlyEnforceIf(equal);
|
||||
model.addDifferent(position, value).onlyEnforceIf(equal.not());
|
||||
return equal;
|
||||
}
|
||||
|
||||
private void changingTime(final CpModel model, final SchedulingInput input) {
|
||||
for (int a = 0; a < input.groups().size(); a++) {
|
||||
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||
if (!java.util.Collections.disjoint(
|
||||
input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) {
|
||||
separate(model, a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void separate(final CpModel model, final int a, final int b) {
|
||||
for (int p = 0; p < loads.length; p++) {
|
||||
final IntVar gap = model.newIntVar(0, present.length * 2L, "gap");
|
||||
model.addAbsEquality(
|
||||
gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1}));
|
||||
model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]});
|
||||
}
|
||||
}
|
||||
|
||||
public List<List<Long>> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) {
|
||||
final List<List<Long>> result = new ArrayList<>();
|
||||
for (int p = 0; p < loads.length; p++) {
|
||||
final int part = p;
|
||||
result.add(IntStream.range(0, present.length)
|
||||
.boxed()
|
||||
.filter((final Integer g) -> solver.booleanValue(present[g][part]))
|
||||
.sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part])))
|
||||
.map((final Integer g) -> input.groups().get(g).groupId())
|
||||
.toList());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import com.google.ortools.sat.LinearExprBuilder;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||
|
||||
public final class SchedulingObjectives {
|
||||
private SchedulingObjectives() {}
|
||||
|
||||
public static LinearExpr attendance(
|
||||
final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||
final Set<Long> dancers = new HashSet<>();
|
||||
final Set<Long> showcases = new HashSet<>();
|
||||
input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds()));
|
||||
input.groups().stream()
|
||||
.filter(GroupRequirement::showcase)
|
||||
.forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds()));
|
||||
dancers.removeAll(input.coachDancerIds());
|
||||
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||
for (final long dancer : dancers) {
|
||||
final IntVar[] parts = dancerParts(model, input, vars, dancer);
|
||||
if (showcases.contains(dancer)) {
|
||||
final IntVar excess = model.newIntVar(0, input.dayParts(), "excess");
|
||||
model.addMaxEquality(excess, new LinearExpr[] {
|
||||
LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0)
|
||||
});
|
||||
penalty.add(excess);
|
||||
} else {
|
||||
model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts());
|
||||
}
|
||||
}
|
||||
return penalty.build();
|
||||
}
|
||||
|
||||
private static IntVar[] dancerParts(
|
||||
final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) {
|
||||
final List<Integer> groups = IntStream.range(0, input.groups().size())
|
||||
.filter((final int g) -> !input.groups().get(g).showcase()
|
||||
&& input.groups().get(g).dancerIds().contains(dancer))
|
||||
.boxed()
|
||||
.toList();
|
||||
final IntVar[] result = new IntVar[input.dayParts()];
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final int part = p;
|
||||
result[p] = model.newBoolVar("dancer" + dancer + ":" + p);
|
||||
if (groups.isEmpty()) {
|
||||
model.addEquality(result[p], 0);
|
||||
} else {
|
||||
model.addMaxEquality(
|
||||
result[p],
|
||||
groups.stream()
|
||||
.map((final Integer g) -> vars.present()[g][part])
|
||||
.toArray(IntVar[]::new));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||
// Every nonempty daypart has load - 1 adjacencies. Remove their common score
|
||||
// to avoid exploring equivalent orders when groups have identical penalties.
|
||||
final long baseline = minimumPenalty(input.groups());
|
||||
final long performances =
|
||||
input.groups().stream().mapToLong(GroupRequirement::appearances).sum();
|
||||
penalty.add(performances * baseline);
|
||||
for (final IntVar load : vars.loads()) {
|
||||
final BoolVar active = model.newBoolVar("nonemptyPart");
|
||||
model.addGreaterThan(load, 0).onlyEnforceIf(active);
|
||||
model.addEquality(load, 0).onlyEnforceIf(active.not());
|
||||
penalty.addTerm(active, -baseline);
|
||||
}
|
||||
for (int a = 0; a < input.groups().size(); a++) {
|
||||
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||
final long score = VarietyScore.penalty(
|
||||
input.groups().get(a), input.groups().get(b))
|
||||
- baseline;
|
||||
if (score > 0) {
|
||||
addAdjacencies(model, vars, new int[] {a, b}, penalty, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
return penalty.build();
|
||||
}
|
||||
|
||||
private static long minimumPenalty(final List<GroupRequirement> groups) {
|
||||
long minimum = 2_000_000;
|
||||
for (int a = 0; a < groups.size(); a++) {
|
||||
for (int b = a + 1; b < groups.size(); b++) {
|
||||
minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b)));
|
||||
}
|
||||
}
|
||||
return groups.size() < 2 ? 0 : minimum;
|
||||
}
|
||||
|
||||
private static void addAdjacencies(
|
||||
final CpModel model,
|
||||
final OrderedAssignments vars,
|
||||
final int[] pair,
|
||||
final LinearExprBuilder penalty,
|
||||
final long score) {
|
||||
for (int p = 0; p < vars.loads().length; p++) {
|
||||
penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score);
|
||||
}
|
||||
}
|
||||
|
||||
private static BoolVar adjacent(
|
||||
final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) {
|
||||
final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap");
|
||||
model.addAbsEquality(
|
||||
gap,
|
||||
LinearExpr.weightedSum(
|
||||
new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1}));
|
||||
final BoolVar adjacent = model.newBoolVar("adjacent");
|
||||
model.addImplication(adjacent, vars.present()[a][p]);
|
||||
model.addImplication(adjacent, vars.present()[b][p]);
|
||||
model.addEquality(gap, 1).onlyEnforceIf(adjacent);
|
||||
model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] {
|
||||
adjacent.not(), vars.present()[a][p], vars.present()[b][p]
|
||||
});
|
||||
return adjacent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.SchedulingInputs;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class S18LifecycleTest {
|
||||
private final SchoolManagementService schools = new SchoolManagementService();
|
||||
private final long school = schools.createSchool("Studio").getId();
|
||||
private final ShowSchedules schedules = new ShowSchedules(schools);
|
||||
|
||||
private List<DayPart> parts(final int n) {
|
||||
return java.util.stream.IntStream.range(0, n)
|
||||
.mapToObj((final int i) -> DayPart.builder()
|
||||
.date(LocalDate.of(2027, 1, 1).plusDays(i))
|
||||
.type(DayPartType.MORNING)
|
||||
.danceGroups(List.of())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Show settings(final int n, final Integer preferredDayParts) {
|
||||
return Show.builder()
|
||||
.name("Show")
|
||||
.dayParts(parts(n))
|
||||
.preferredDayParts(preferredDayParts)
|
||||
.build();
|
||||
}
|
||||
|
||||
private DanceGroup group(final DanceGroupType type) {
|
||||
final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
final long dancer =
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId();
|
||||
schools.addGroupDancer(school, group.getId(), dancer);
|
||||
return group;
|
||||
}
|
||||
|
||||
public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() {
|
||||
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||
assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId()));
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of()));
|
||||
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||
schedules.finalizeSchedule(school, show.getId());
|
||||
assertTrue(show.isFinalized());
|
||||
final SchoolSnapshot snapshot = schools.exportSchool(school);
|
||||
assertTrue(snapshot.shows().getFirst().finalized());
|
||||
final Show imported = snapshot.restore().getShows().iterator().next();
|
||||
assertFalse(imported.isFinalized());
|
||||
assertEquals(1, imported.getPreferredDayParts());
|
||||
assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size());
|
||||
schedules.configure(school, show.getId(), settings(2, 1));
|
||||
assertTrue(show.isFinalized());
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId())));
|
||||
assertFalse(show.isFinalized());
|
||||
schedules.finalizeSchedule(school, show.getId());
|
||||
schools.removeGroupDancer(
|
||||
school, group.getId(), group.getDancers().iterator().next().getId());
|
||||
assertFalse(show.isFinalized());
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() {
|
||||
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of()));
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
final SchoolSnapshot before = schools.exportSchool(school);
|
||||
assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of()));
|
||||
assertThrows(
|
||||
ManagementException.class,
|
||||
() -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L))));
|
||||
assertEquals(before, schools.exportSchool(school));
|
||||
}
|
||||
|
||||
public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() {
|
||||
final Show one = schedules.configure(school, null, settings(1, null));
|
||||
assertEquals(1, one.getPreferredDayParts());
|
||||
final Show show = schedules.configure(school, null, settings(3, null));
|
||||
assertEquals(2, show.getPreferredDayParts());
|
||||
schedules.configure(school, show.getId(), settings(1, null));
|
||||
assertEquals(2, show.getPreferredDayParts());
|
||||
assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId()));
|
||||
schedules.configure(school, show.getId(), settings(1, 1));
|
||||
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||
final int before = schools.shows(school).size();
|
||||
assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2)));
|
||||
assertEquals(before, schools.shows(school).size());
|
||||
schedules.configure(school, show.getId(), settings(0, null));
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() {
|
||||
group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(1, 1));
|
||||
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||
final SchoolSnapshot before = schools.exportSchool(school);
|
||||
final ManagementException error = assertThrows(
|
||||
ManagementException.class,
|
||||
() -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId()));
|
||||
assertTrue(error.getMessage().contains("limit"));
|
||||
assertFalse(error.getMessage().contains("infeasible"));
|
||||
assertEquals(before, schools.exportSchool(school));
|
||||
}
|
||||
|
||||
public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() {
|
||||
final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION);
|
||||
group(DanceGroupType.PRE_SCHOOLER);
|
||||
schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of()));
|
||||
final Show show = schedules.configure(school, null, settings(3, 1));
|
||||
final SchedulingInput input = schools.beginScheduling(school, show.getId());
|
||||
assertEquals(2, input.groups().size());
|
||||
assertEquals(26, input.groups().getFirst().averageAge());
|
||||
assertEquals(1, input.groups().getFirst().appearances());
|
||||
schools.endScheduling(show.getId());
|
||||
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||
assertFalse(show.isFinalized());
|
||||
final SchedulingInput stale = schools.beginScheduling(school, show.getId());
|
||||
final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale);
|
||||
schools.updateDancer(
|
||||
school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1));
|
||||
assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result));
|
||||
schools.endScheduling(show.getId());
|
||||
assertEquals(
|
||||
2, SchedulingInputs.of(schools.school(school), show, 0).groups().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@QuarkusTest
|
||||
class S18ResourceTest {
|
||||
private static final String ROOT = "/api/dance-schools";
|
||||
|
||||
private static long create(final String url, final Object body) {
|
||||
return given().contentType("application/json")
|
||||
.body(body)
|
||||
.post(url)
|
||||
.then()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.jsonPath()
|
||||
.getLong("id");
|
||||
}
|
||||
|
||||
private static long dancer(final String school, final String name) {
|
||||
return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01"));
|
||||
}
|
||||
|
||||
private static long group(final String school, final long dancer) {
|
||||
final long group = create(
|
||||
school + "/dance-groups",
|
||||
Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of()));
|
||||
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
return group;
|
||||
}
|
||||
|
||||
private static String show(final String school) {
|
||||
return school + "/shows/"
|
||||
+ create(
|
||||
school + "/shows",
|
||||
Map.of(
|
||||
"name",
|
||||
"Show",
|
||||
"preferredDayParts",
|
||||
1,
|
||||
"dayParts",
|
||||
List.of(Map.of("date", "2027-01-01", "type", "MORNING"))));
|
||||
}
|
||||
|
||||
public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() {
|
||||
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle"));
|
||||
try {
|
||||
final long dancer = dancer(school, "Dancer");
|
||||
final long group = group(school, dancer);
|
||||
final String show = show(school);
|
||||
given().post(show + "/finalize").then().statusCode(400);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("assignments", List.of(List.of(group))))
|
||||
.put(show + "/assignments")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("finalized", equalTo(false), "violations", empty());
|
||||
given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true));
|
||||
given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1));
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("assignments", List.of(List.of(group, group))))
|
||||
.put(show + "/assignments")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
final String snapshot = given().get(school + "/export").asString();
|
||||
given().contentType("application/json")
|
||||
.body(snapshot)
|
||||
.post(ROOT + "/import/preview")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() {
|
||||
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches"));
|
||||
try {
|
||||
final long dancer = dancer(school, "Coach");
|
||||
group(school, dancer);
|
||||
final String show = show(school);
|
||||
given().post(show + "/schedule").then().statusCode(200);
|
||||
given().post(show + "/finalize").then().statusCode(200);
|
||||
final long coach = given().contentType("application/json")
|
||||
.body(Map.of("dancerId", dancer))
|
||||
.post(school + "/coaches")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.jsonPath()
|
||||
.getLong("id");
|
||||
given().get(show).then().body("finalized", equalTo(false));
|
||||
given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer));
|
||||
final String exported = given().get(school + "/export").asString();
|
||||
given().contentType("application/json")
|
||||
.body(exported)
|
||||
.post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
||||
.then()
|
||||
.statusCode(200);
|
||||
given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1"));
|
||||
final long importedDancer =
|
||||
given().get(school + "/dancers").jsonPath().getLong("[0].id");
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", importedDancer))
|
||||
.put(school + "/coaches/0")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
given().delete(school + "/coaches/0/dancer").then().statusCode(204);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", -1))
|
||||
.post(school + "/coaches")
|
||||
.then()
|
||||
.statusCode(404);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", importedDancer))
|
||||
.put(school + "/coaches/9999")
|
||||
.then()
|
||||
.statusCode(404);
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class S18SolverTest {
|
||||
private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) {
|
||||
return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20);
|
||||
}
|
||||
|
||||
private SchedulingResult solve(final SchedulingInput input) {
|
||||
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
|
||||
assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty());
|
||||
assertTrue(result.optimal());
|
||||
return result;
|
||||
}
|
||||
|
||||
public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() {
|
||||
final List<GroupRequirement> groups = List.of(
|
||||
group(1, 2, true, 1),
|
||||
group(2, 2, true, 2),
|
||||
group(3, 2, true, 3),
|
||||
group(4, 1, false, 1),
|
||||
group(5, 1, false, 5),
|
||||
group(6, 1, false, 6),
|
||||
group(7, 1, false, 7));
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of());
|
||||
final SchedulingResult result = solve(input);
|
||||
for (final List<Long> part : result.assignments()) {
|
||||
assertTrue(part.getFirst() <= 3);
|
||||
assertTrue(part.getLast() <= 3);
|
||||
}
|
||||
assertEquals(
|
||||
1,
|
||||
result.assignments().stream()
|
||||
.filter((final List<Long> p) -> p.contains(4L))
|
||||
.count());
|
||||
}
|
||||
|
||||
public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() {
|
||||
final SchedulingInput input = new SchedulingInput(
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)),
|
||||
1,
|
||||
Set.of());
|
||||
assertThrows(ManagementException.class, () -> solve(input));
|
||||
final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L));
|
||||
final SchedulingResult result = solve(coach);
|
||||
assertTrue(result.assignments().stream().allMatch((final List<Long> p) -> p.size() == 2));
|
||||
}
|
||||
|
||||
public @Test void showcaseExcessTakesPriorityOverPerfectBalance() {
|
||||
final SchedulingInput input = new SchedulingInput(
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
List.of(
|
||||
group(1, 2, true, 1),
|
||||
group(2, 1, false, 1),
|
||||
group(3, 1, false, 1),
|
||||
group(4, 1, false, 4),
|
||||
group(5, 1, false, 5),
|
||||
group(6, 1, false, 6),
|
||||
group(7, 1, false, 7),
|
||||
group(8, 1, false, 8),
|
||||
group(9, 1, false, 9),
|
||||
group(10, 1, false, 10),
|
||||
group(11, 1, false, 11)),
|
||||
1,
|
||||
Set.of());
|
||||
final SchedulingResult result = solve(input);
|
||||
assertTrue(result.assignments().stream().anyMatch((final List<Long> p) -> p.containsAll(List.of(2L, 3L))));
|
||||
assertEquals(
|
||||
2,
|
||||
Math.abs(result.assignments().get(0).size()
|
||||
- result.assignments().get(1).size()));
|
||||
}
|
||||
|
||||
public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() {
|
||||
final SchedulingInput input =
|
||||
new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L));
|
||||
assertThrows(ManagementException.class, () -> solve(input));
|
||||
assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream()
|
||||
.anyMatch((final String s) -> s.contains("two intervening")));
|
||||
}
|
||||
|
||||
public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() {
|
||||
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of()));
|
||||
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()));
|
||||
}
|
||||
|
||||
public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() {
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of());
|
||||
assertFalse(ScheduleRules.violations(input, null).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of()).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty());
|
||||
final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of());
|
||||
assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() {
|
||||
final GroupRequirement hiphop =
|
||||
new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10);
|
||||
final GroupRequirement mixed = new GroupRequirement(
|
||||
2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5);
|
||||
final GroupRequirement modern =
|
||||
new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15);
|
||||
assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed));
|
||||
assertEquals(0, VarietyScore.penalty(hiphop, modern));
|
||||
final List<Long> order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of()))
|
||||
.assignments()
|
||||
.getFirst();
|
||||
assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L)));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,12 @@ class SchedulingTest {
|
||||
}
|
||||
|
||||
private DanceGroup group(final DanceGroupType type) {
|
||||
return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
final DanceGroup group = service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
service.addGroupDancer(
|
||||
school,
|
||||
group.getId(),
|
||||
service.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
return group;
|
||||
}
|
||||
|
||||
private void run(final Show show) {
|
||||
@@ -86,7 +91,7 @@ class SchedulingTest {
|
||||
final Show show = service.createShow(school, "Show", List.of(), null);
|
||||
assertTrue(show.getAppearances().isEmpty());
|
||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
||||
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||
assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||
final var counts = new EnumMap<>(show.getAppearances());
|
||||
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||
|
||||
@@ -171,15 +171,17 @@ class SchoolManagementResourceTest {
|
||||
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
||||
given().post(show + "/schedule").then().statusCode(400);
|
||||
final long group = create(school + "/dance-groups", GROUP);
|
||||
final long dancer = create(school + "/dancers", DANCER);
|
||||
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
||||
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||
final String before = given().get(show).asString();
|
||||
final String counts =
|
||||
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
||||
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
|
||||
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
||||
final String body =
|
||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
|
||||
+ counts.formatted(count) + "}";
|
||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
|
||||
+ count + "}";
|
||||
put(show, body).statusCode(400);
|
||||
given().get(show).then().body(equalTo(before));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("Content-Disposition", containsString(".json"))
|
||||
.body("formatVersion", equalTo(2))
|
||||
.body("formatVersion", equalTo(3))
|
||||
.extract()
|
||||
.asString();
|
||||
try {
|
||||
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
|
||||
for (final String bad : new String[] {
|
||||
"{",
|
||||
"null",
|
||||
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
||||
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
|
||||
json.replace("\"name\" :", "\"unknown\" :"),
|
||||
json + " {}",
|
||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
||||
|
||||
@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
|
||||
@Test
|
||||
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
||||
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
|
||||
final ShowRequest show = new ShowRequest("Show", List.of(), counts);
|
||||
final ShowRequest show = new ShowRequest("Show", List.of(), counts, null);
|
||||
counts.clear();
|
||||
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
||||
assertThrows(
|
||||
|
||||
@@ -29,7 +29,7 @@ class SchedulingServiceTest {
|
||||
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||
final var schools = new SchoolManagementService();
|
||||
final long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
final Show show = schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
|
||||
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||
final var schools = new SchoolManagementService();
|
||||
final long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
final Show show = schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
|
||||
ManagementExceptionKind.CONFLICT,
|
||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
||||
.getKind());
|
||||
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
|
||||
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
||||
}
|
||||
|
||||
private static void populatedGroup(final SchoolManagementService schools, final long school) {
|
||||
final long group = schools.createGroup(
|
||||
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||
.getId();
|
||||
schools.addGroupDancer(
|
||||
school,
|
||||
group,
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
}
|
||||
|
||||
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
|
||||
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
||||
entered.countDown();
|
||||
|
||||
@@ -31,6 +31,7 @@ async function confirmDelete(page: Page) {
|
||||
|
||||
test('manages a school, shared memberships and shows through the real REST API', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const schoolName = `E2E Studio ${Date.now()}`;
|
||||
const browserErrors: string[] = [];
|
||||
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
|
||||
|
||||
// Two independent performances give Sam time to change between the shared groups.
|
||||
for (const name of ['Modern adults', 'Streetdance adults']) {
|
||||
const dancerResponse = await request.post(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
|
||||
{
|
||||
data: { name, birthDate: '1990-01-01' },
|
||||
},
|
||||
);
|
||||
const dancer = (await dancerResponse.json()) as { id: number };
|
||||
const groupResponse = await request.post(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
|
||||
{
|
||||
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
|
||||
},
|
||||
);
|
||||
const group = (await groupResponse.json()) as { id: number };
|
||||
await request.put(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
|
||||
);
|
||||
}
|
||||
await tab(page, 'Shows');
|
||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||
@@ -120,8 +141,19 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
await expect(page.locator('.date-tile')).toContainText('jul 2027');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: /Schedule groups/ }).click();
|
||||
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible();
|
||||
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible();
|
||||
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
||||
const scheduleDialog = page.getByRole('dialog');
|
||||
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
|
||||
await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible();
|
||||
await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click();
|
||||
await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click();
|
||||
await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible();
|
||||
await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled();
|
||||
await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
|
||||
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
|
||||
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
|
||||
@@ -148,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
await tab(page, 'Dansers');
|
||||
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
|
||||
await confirmDelete(page);
|
||||
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0);
|
||||
await tab(page, 'Dansgroepen');
|
||||
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
|
||||
@@ -166,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
expect(browserErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('links a coach and finalizes performances spread across multiple dates', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const schoolResponse = await request.post(`${backend}/api/dance-schools`, {
|
||||
data: { name: `Coach show ${Date.now()}` },
|
||||
});
|
||||
const school = (await schoolResponse.json()) as School;
|
||||
schoolToDelete = school.id;
|
||||
const base = `${backend}/api/dance-schools/${school.id}`;
|
||||
const dancerResponse = await request.post(`${base}/dancers`, {
|
||||
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
|
||||
});
|
||||
const dancer = (await dancerResponse.json()) as { id: number };
|
||||
const groupIds: number[] = [];
|
||||
for (const name of ['Preschool', 'Adults']) {
|
||||
const response = await request.post(`${base}/dance-groups`, {
|
||||
data: {
|
||||
category: name,
|
||||
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
|
||||
startAge: 0,
|
||||
endAge: 99,
|
||||
styles: [],
|
||||
},
|
||||
});
|
||||
const group = (await response.json()) as { id: number };
|
||||
groupIds.push(group.id);
|
||||
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
|
||||
}
|
||||
const showResponse = await request.post(`${base}/shows`, {
|
||||
data: {
|
||||
name: 'Coach across dates',
|
||||
preferredDayParts: 1,
|
||||
dayParts: [
|
||||
{ date: '2027-06-01', type: 'AFTERNOON' },
|
||||
{ date: '2027-06-02', type: 'AFTERNOON' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const show = (await showResponse.json()) as { id: number };
|
||||
await request.put(`${base}/shows/${show.id}/assignments`, {
|
||||
data: { assignments: groupIds.map((id) => [id]) },
|
||||
});
|
||||
await page.goto(`/schools/${school.id}/shows`);
|
||||
await expect(page.getByText(/exceeds preferredDayParts=1/)).toBeVisible();
|
||||
await tab(page, 'Dansers');
|
||||
await page.getByText('Coaches koppelen aan dansers', { exact: true }).click();
|
||||
await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' });
|
||||
await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible();
|
||||
await tab(page, 'Shows');
|
||||
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click();
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }),
|
||||
).toBeDisabled();
|
||||
const finalResponse = await request.get(`${base}/shows/${show.id}`);
|
||||
expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true);
|
||||
});
|
||||
|
||||
test('shows a useful connection error and recovers on retry', async ({ page }) => {
|
||||
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
|
||||
await page.goto('/schools');
|
||||
|
||||
@@ -27,11 +27,19 @@ export interface DayPart {
|
||||
type: DayPartType;
|
||||
danceGroupIds: number[];
|
||||
}
|
||||
export interface Coach {
|
||||
id: number;
|
||||
name: string;
|
||||
dancerId: number | null;
|
||||
}
|
||||
export interface Show {
|
||||
id: number;
|
||||
name: string;
|
||||
dayParts: DayPart[];
|
||||
appearances: Partial<Record<GroupType, number>>;
|
||||
preferredDayParts: number;
|
||||
finalized: boolean;
|
||||
violations: string[];
|
||||
}
|
||||
export interface SchoolRequest {
|
||||
name: string;
|
||||
@@ -51,6 +59,7 @@ export interface ShowRequest {
|
||||
name: string;
|
||||
dayParts: { date: string; type: DayPartType }[];
|
||||
appearances?: Partial<Record<GroupType, number>>;
|
||||
preferredDayParts?: number;
|
||||
}
|
||||
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
|
||||
export type EditorTarget =
|
||||
@@ -63,6 +72,7 @@ export interface SchoolData {
|
||||
dancers: Dancer[];
|
||||
groups: DanceGroup[];
|
||||
shows: Show[];
|
||||
coaches: Coach[];
|
||||
}
|
||||
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
|
||||
{ value: 'REGULAR', label: 'Regulier' },
|
||||
|
||||
@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
|
||||
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
||||
expect(received).not.toHaveBeenCalled();
|
||||
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
||||
expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] });
|
||||
http.expectOne('/api/dance-schools/7/coaches').flush([]);
|
||||
expect(received).toHaveBeenCalledWith({
|
||||
school,
|
||||
dancers: [],
|
||||
groups: [],
|
||||
shows: [],
|
||||
coaches: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates an existing entity even when its id is zero', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import {
|
||||
Coach,
|
||||
DanceGroup,
|
||||
Dancer,
|
||||
DancerRequest,
|
||||
@@ -29,6 +30,7 @@ export class SchoolApi {
|
||||
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
||||
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
||||
shows: this.http.get<Show[]>(`${base}/shows`),
|
||||
coaches: this.http.get<Coach[]>(`${base}/coaches`),
|
||||
});
|
||||
}
|
||||
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
||||
@@ -52,6 +54,20 @@ export class SchoolApi {
|
||||
{ timeout: 1860000 },
|
||||
);
|
||||
}
|
||||
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
||||
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
||||
assignments,
|
||||
});
|
||||
}
|
||||
finalize(schoolId: number, showId: number): Observable<Show> {
|
||||
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
|
||||
}
|
||||
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
|
||||
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
|
||||
}
|
||||
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
|
||||
}
|
||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||
kind
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
</section>
|
||||
}
|
||||
@case ('dancers') {
|
||||
<app-coach-editor [data]="context" (changed)="reload()" />
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Dansers</h2>
|
||||
@@ -350,6 +351,16 @@
|
||||
</div>
|
||||
<div class="show-details">
|
||||
<h3>{{ show.name }}</h3>
|
||||
<p>
|
||||
{{ show.finalized ? 'Definitief' : 'Concept' }} · preferredDayParts =
|
||||
{{ show.preferredDayParts }}
|
||||
</p>
|
||||
@for (violation of show.violations; track $index) {
|
||||
<p class="alert error">{{ violation }}</p>
|
||||
}
|
||||
<button type="button" class="button secondary" (click)="scheduleEditing.set(show)">
|
||||
Indeling en volgorde bewerken
|
||||
</button>
|
||||
<p>{{ dateRange(show) }}</p>
|
||||
@for (part of show.dayParts; track $index) {
|
||||
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
|
||||
@@ -357,7 +368,7 @@
|
||||
<p role="status">
|
||||
{{
|
||||
scheduleValid(show)
|
||||
? 'Planning voldoet aan alle optredensaantallen.'
|
||||
? 'Planning voldoet aan alle harde regels.'
|
||||
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
||||
}}
|
||||
</p>
|
||||
@@ -365,7 +376,10 @@
|
||||
type="button"
|
||||
class="button primary"
|
||||
[disabled]="
|
||||
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
|
||||
scheduling().includes(show.id) ||
|
||||
!context.groups.length ||
|
||||
!show.dayParts.length ||
|
||||
show.preferredDayParts > show.dayParts.length
|
||||
"
|
||||
(click)="schedule(show)"
|
||||
>
|
||||
@@ -426,6 +440,15 @@
|
||||
(saved)="saved()"
|
||||
/>
|
||||
}
|
||||
@if (scheduleEditing(); as show) {
|
||||
<app-schedule-editor
|
||||
[show]="show"
|
||||
[groups]="context.groups"
|
||||
[schoolId]="context.school.id"
|
||||
(closed)="scheduleEditing.set(null)"
|
||||
(saved)="reload()"
|
||||
/>
|
||||
}
|
||||
@if (members(); as group) {
|
||||
<app-group-members
|
||||
[group]="group"
|
||||
|
||||
@@ -17,6 +17,8 @@ import { apiError } from '../core/api-error';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { EntityEditor } from '../shared/entity-editor';
|
||||
import { ConfirmDialog } from '../shared/confirm-dialog';
|
||||
import { ScheduleEditor } from '../shared/schedule-editor';
|
||||
import { CoachEditor } from '../shared/coach-editor';
|
||||
import { GroupMembers } from '../shared/group-members';
|
||||
|
||||
interface DeleteTarget {
|
||||
@@ -29,6 +31,8 @@ interface DeleteTarget {
|
||||
@Component({
|
||||
selector: 'app-school-page',
|
||||
imports: [
|
||||
ScheduleEditor,
|
||||
CoachEditor,
|
||||
SchoolTransfer,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
|
||||
readonly error = signal('');
|
||||
readonly notice = signal('');
|
||||
readonly editor = signal<EditorTarget | null>(null);
|
||||
readonly scheduleEditing = signal<Show | null>(null);
|
||||
readonly members = signal<DanceGroup | null>(null);
|
||||
readonly deleting = signal<DeleteTarget | null>(null);
|
||||
readonly scheduling = signal<number[]>([]);
|
||||
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
|
||||
this.search.set('');
|
||||
this.editor.set(null);
|
||||
this.members.set(null);
|
||||
this.scheduleEditing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.section.set(params.get('section') ?? 'overview');
|
||||
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
|
||||
@@ -121,22 +127,9 @@ export class SchoolPage implements OnInit {
|
||||
return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
|
||||
}
|
||||
scheduleValid(show: Show): boolean {
|
||||
const groups = this.data()?.groups ?? [];
|
||||
return (
|
||||
!!groups.length &&
|
||||
!!show.dayParts.length &&
|
||||
groups.every(
|
||||
(g) =>
|
||||
show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length ===
|
||||
show.appearances[g.type],
|
||||
) &&
|
||||
show.dayParts.every(
|
||||
(p) =>
|
||||
new Set(p.danceGroupIds).size === p.danceGroupIds.length &&
|
||||
p.danceGroupIds.every((id) => groups.some((g) => g.id === id)),
|
||||
)
|
||||
);
|
||||
return show.violations?.length === 0;
|
||||
}
|
||||
|
||||
groupNamesFor(ids: number[]): string {
|
||||
return (
|
||||
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
|
||||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
|
||||
try {
|
||||
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
||||
this.notice.set(
|
||||
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
|
||||
result.optimal
|
||||
? 'Optimaal conceptrooster.'
|
||||
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
|
||||
);
|
||||
await this.reload();
|
||||
} catch (error: unknown) {
|
||||
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
|
||||
this.deleting.set(target);
|
||||
}
|
||||
membershipChanged(ids: number[]): void {
|
||||
void this.reload();
|
||||
const groupId = this.members()?.id;
|
||||
this.data.update((data) =>
|
||||
data
|
||||
|
||||
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal file
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SchoolData } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-coach-editor',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<details>
|
||||
<summary>Coaches koppelen aan dansers</summary>
|
||||
<p>
|
||||
Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze
|
||||
meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht.
|
||||
</p>
|
||||
@if (error()) {
|
||||
<p role="alert" class="alert error">{{ error() }}</p>
|
||||
}
|
||||
<fieldset [disabled]="busy()">
|
||||
@for (coach of data().coaches; track coach.id) {
|
||||
<label
|
||||
>{{ coach.name }}
|
||||
<select [ngModel]="coach.dancerId" (ngModelChange)="link($event, coach.id)">
|
||||
<option [ngValue]="null">Geen danser gekoppeld</option>
|
||||
@for (dancer of data().dancers; track dancer.id) {
|
||||
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<label
|
||||
>Danser als coach registreren
|
||||
<select [ngModel]="null" (ngModelChange)="link($event)">
|
||||
<option [ngValue]="null">Kies een danser</option>
|
||||
@for (dancer of data().dancers; track dancer.id) {
|
||||
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
</details>
|
||||
`,
|
||||
})
|
||||
export class CoachEditor {
|
||||
readonly data = input.required<SchoolData>();
|
||||
readonly changed = output<void>();
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
private readonly api = inject(SchoolApi);
|
||||
|
||||
async link(dancerId: number | null, coachId?: number): Promise<void> {
|
||||
if (this.busy() || (dancerId === null && coachId === undefined)) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
await firstValueFrom<unknown>(
|
||||
dancerId === null
|
||||
? this.api.unlinkCoach(this.data().school.id, coachId!)
|
||||
: this.api.linkCoach(this.data().school.id, dancerId, coachId),
|
||||
);
|
||||
this.changed.emit();
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
|
||||
const show: Show = {
|
||||
id: 3,
|
||||
name: 'Summer',
|
||||
preferredDayParts: 2,
|
||||
finalized: false,
|
||||
violations: [],
|
||||
dayParts: [
|
||||
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||
@@ -77,7 +80,7 @@ describe('EntityEditor', () => {
|
||||
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
|
||||
};
|
||||
|
||||
it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => {
|
||||
it('defaults preferredDayParts to two and rejects duplicate dated dayparts and fractional preferredDayParts', () => {
|
||||
const component = editor({ kind: 'show' });
|
||||
component.name = 'Show';
|
||||
expect(component.validDetails()).toBe(true);
|
||||
@@ -87,31 +90,45 @@ describe('EntityEditor', () => {
|
||||
component.dayParts[1].date = '2027-06-02';
|
||||
component.addPart();
|
||||
component.dayParts[2].date = '2027-06-03';
|
||||
expect(component.appearances).toEqual({
|
||||
REGULAR: 2,
|
||||
SELECTION: 2,
|
||||
PRE_SCHOOLER: 1,
|
||||
SHOW_CASE: 3,
|
||||
PRE_EDUCATION: 3,
|
||||
});
|
||||
expect(component.preferredDayParts).toBe(2);
|
||||
expect(component.validDetails()).toBe(true);
|
||||
component.dayParts[2].date = '2027-06-02';
|
||||
expect(component.validDetails()).toBe(false);
|
||||
component.dayParts[2].date = '2027-06-03';
|
||||
component.appearances.PRE_SCHOOLER = 1.5;
|
||||
component.preferredDayParts = 1.5;
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
|
||||
it('preserves an explicitly chosen preferredDayParts when adding dayparts to a new show', async () => {
|
||||
api.saveShow.mockReturnValue(of(show));
|
||||
const component = editor({ kind: 'show' });
|
||||
component.name = 'Show';
|
||||
component.addPart();
|
||||
component.dayParts[0].date = '2027-06-01';
|
||||
component.addPart();
|
||||
component.dayParts[1].date = '2027-06-02';
|
||||
component.preferredDayParts = 1;
|
||||
component.addPart();
|
||||
component.dayParts[2].date = '2027-06-03';
|
||||
expect(component.preferredDayParts).toBe(1);
|
||||
await component.save();
|
||||
expect(api.saveShow).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ preferredDayParts: 1 }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('retains preferredDayParts when dayparts are removed so it can be explicitly corrected', () => {
|
||||
const component = editor({ kind: 'show', value: show });
|
||||
component.addPart();
|
||||
component.dayParts[2].date = '2027-06-03';
|
||||
expect(component.appearances.SHOW_CASE).toBe(2);
|
||||
expect(component.preferredDayParts).toBe(2);
|
||||
component.removePart(2);
|
||||
component.removePart(1);
|
||||
expect(component.appearances.REGULAR).toBe(2);
|
||||
expect(component.validDetails()).toBe(false);
|
||||
component.setSingleCounts();
|
||||
expect(component.preferredDayParts).toBe(2);
|
||||
expect(component.validDetails()).toBe(true);
|
||||
component.preferredDayParts = 1;
|
||||
expect(component.validDetails()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -120,11 +137,11 @@ describe('EntityEditor', () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
try {
|
||||
const component = editor({ kind: 'show', value: show });
|
||||
component.appearances.REGULAR = 1;
|
||||
component.preferredDayParts = 1;
|
||||
await component.save();
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
|
||||
expect(api.saveShow).not.toHaveBeenCalled();
|
||||
component.appearances.REGULAR = 2;
|
||||
component.preferredDayParts = 2;
|
||||
confirm.mockClear();
|
||||
component.name = 'Renamed';
|
||||
await component.save();
|
||||
@@ -147,7 +164,7 @@ describe('EntityEditor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('displays one disabled appearance field for every type with one daypart', async () => {
|
||||
it('displays one editable preferredDayParts field with a default of one for a one-daypart show', async () => {
|
||||
// jsdom does not implement native dialog opening.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
|
||||
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
||||
@@ -163,8 +180,8 @@ describe('EntityEditor', () => {
|
||||
fixture.detectChanges();
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
|
||||
expect(inputs).toHaveLength(5);
|
||||
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true);
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
|
||||
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');
|
||||
|
||||
@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
|
||||
</button>
|
||||
</fieldset>
|
||||
@if (dayParts.length) {
|
||||
<fieldset>
|
||||
<legend>Optredens per groepstype</legend>
|
||||
@for (option of types; track option.value) {
|
||||
<label
|
||||
>{{ option.label
|
||||
}}<input
|
||||
>Gewenst aantal dagdelen (x)
|
||||
<input
|
||||
type="number"
|
||||
[name]="'count' + option.value"
|
||||
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
|
||||
(ngModelChange)="appearances[option.value] = $event"
|
||||
required
|
||||
name="preferredDayParts"
|
||||
[(ngModel)]="preferredDayParts"
|
||||
min="1"
|
||||
[max]="dayParts.length"
|
||||
max="6"
|
||||
step="1"
|
||||
[disabled]="dayParts.length === 1"
|
||||
/></label>
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
|
||||
vóór opslaan.
|
||||
Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
|
||||
dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
|
||||
</p>
|
||||
@if (dayParts.length === 1 && !validCounts()) {
|
||||
<button type="button" class="button secondary" (click)="setSingleCounts()">
|
||||
Alle aantallen op één zetten
|
||||
</button>
|
||||
}
|
||||
</fieldset>
|
||||
} @else {
|
||||
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
|
||||
}
|
||||
@@ -196,8 +185,16 @@ export class EntityEditor implements OnInit {
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
private readonly api = inject(SchoolApi);
|
||||
dayParts: DayPart[] = [];
|
||||
appearances: Partial<Record<GroupType, number>> = {};
|
||||
private countsInitialized = false;
|
||||
private preferredDayPartsValue = 2;
|
||||
private preferredDayPartsEdited = false;
|
||||
|
||||
get preferredDayParts(): number {
|
||||
return this.preferredDayPartsValue;
|
||||
}
|
||||
set preferredDayParts(value: number) {
|
||||
this.preferredDayPartsValue = value;
|
||||
this.preferredDayPartsEdited = true;
|
||||
}
|
||||
name = '';
|
||||
date = '';
|
||||
type: GroupType = 'REGULAR';
|
||||
@@ -223,44 +220,24 @@ export class EntityEditor implements OnInit {
|
||||
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
||||
if (target.kind === 'show') {
|
||||
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
|
||||
this.appearances = { ...target.value.appearances };
|
||||
this.countsInitialized = Object.keys(this.appearances).length > 0;
|
||||
this.preferredDayParts = target.value.preferredDayParts;
|
||||
}
|
||||
}
|
||||
}
|
||||
addPart(): void {
|
||||
if (this.dayParts.length >= 6) return;
|
||||
const previous = this.dayParts.length;
|
||||
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
|
||||
if (!this.countsInitialized) {
|
||||
for (const { value } of this.types) {
|
||||
const defaultCount = (n: number) =>
|
||||
value === 'PRE_SCHOOLER'
|
||||
? 1
|
||||
: value === 'SHOW_CASE' || value === 'PRE_EDUCATION'
|
||||
? n
|
||||
: Math.min(2, n);
|
||||
if (
|
||||
this.appearances[value] === undefined ||
|
||||
this.appearances[value] === defaultCount(previous)
|
||||
)
|
||||
this.appearances[value] = defaultCount(this.dayParts.length);
|
||||
}
|
||||
}
|
||||
if (!this.target().value && !this.preferredDayPartsEdited)
|
||||
this.preferredDayPartsValue = Math.min(2, this.dayParts.length);
|
||||
}
|
||||
removePart(index: number): void {
|
||||
this.dayParts.splice(index, 1);
|
||||
if (!this.countsInitialized && !this.dayParts.length) this.appearances = {};
|
||||
}
|
||||
setSingleCounts(): void {
|
||||
for (const option of this.types) this.appearances[option.value] = 1;
|
||||
}
|
||||
validCounts(): boolean {
|
||||
return this.types.every(
|
||||
(o) =>
|
||||
Number.isInteger(this.appearances[o.value]) &&
|
||||
this.appearances[o.value]! >= 1 &&
|
||||
this.appearances[o.value]! <= this.dayParts.length,
|
||||
return (
|
||||
Number.isInteger(this.preferredDayParts) &&
|
||||
this.preferredDayParts >= 1 &&
|
||||
this.preferredDayParts <= 6
|
||||
);
|
||||
}
|
||||
toggleStyle(style: GroupStyle, checked: boolean): void {
|
||||
@@ -300,7 +277,7 @@ export class EntityEditor implements OnInit {
|
||||
current.value &&
|
||||
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
|
||||
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
|
||||
JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances))
|
||||
current.value.preferredDayParts !== this.preferredDayParts)
|
||||
)
|
||||
affected = [current.value];
|
||||
const names = affected
|
||||
@@ -349,7 +326,7 @@ export class EntityEditor implements OnInit {
|
||||
{
|
||||
name: this.name.trim(),
|
||||
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
|
||||
appearances: this.appearances,
|
||||
preferredDayParts: this.preferredDayParts,
|
||||
},
|
||||
id,
|
||||
);
|
||||
|
||||
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal file
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Show, DanceGroup } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { ScheduleEditor } from './schedule-editor';
|
||||
|
||||
const groups: DanceGroup[] = [1, 2, 3].map((id) => ({
|
||||
id,
|
||||
category: `Group ${id}`,
|
||||
type: 'REGULAR',
|
||||
startAge: 0,
|
||||
endAge: 99,
|
||||
styles: [],
|
||||
dancerIds: [id],
|
||||
}));
|
||||
const show: Show = {
|
||||
id: 4,
|
||||
name: 'Show',
|
||||
preferredDayParts: 1,
|
||||
finalized: true,
|
||||
violations: [],
|
||||
appearances: {},
|
||||
dayParts: [
|
||||
{ date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] },
|
||||
{ date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] },
|
||||
],
|
||||
};
|
||||
|
||||
describe('ScheduleEditor', () => {
|
||||
const api = { saveAssignments: vi.fn(), finalize: vi.fn() };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] });
|
||||
});
|
||||
function editor() {
|
||||
const fixture = TestBed.createComponent(ScheduleEditor);
|
||||
fixture.componentRef.setInput('show', show);
|
||||
fixture.componentRef.setInput('groups', groups);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
it('edits order and dayparts without changing the saved input', () => {
|
||||
const component = editor();
|
||||
component.reorder(0, 1, -1);
|
||||
component.move(0, 0, 1);
|
||||
component.add(0, 3);
|
||||
component.remove(1, 0);
|
||||
expect(component.assignments).toEqual([[1, 3], [2]]);
|
||||
expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]);
|
||||
expect(component.dirty()).toBe(true);
|
||||
});
|
||||
it('saves invalid drafts and displays server violations without finalizing', async () => {
|
||||
const component = editor();
|
||||
component.remove(0, 0);
|
||||
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
|
||||
api.saveAssignments.mockReturnValue(of(draft));
|
||||
await component.save();
|
||||
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
|
||||
expect(component.current()).toEqual(draft);
|
||||
expect(component.dirty()).toBe(false);
|
||||
expect(api.finalize).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps unsaved edits when saving fails', async () => {
|
||||
const component = editor();
|
||||
component.remove(0, 0);
|
||||
api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||
await component.save();
|
||||
expect(component.assignments).toEqual([[2], [3]]);
|
||||
expect(component.dirty()).toBe(true);
|
||||
expect(component.error()).toContain('niet bereikbaar');
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
it('finalizes through the server and reports a stale validation failure', async () => {
|
||||
const component = editor();
|
||||
api.finalize.mockReturnValue(of(show));
|
||||
await component.finalize();
|
||||
expect(component.current()?.finalized).toBe(true);
|
||||
api.finalize.mockReturnValue(
|
||||
throwError(
|
||||
() => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }),
|
||||
),
|
||||
);
|
||||
await component.finalize();
|
||||
expect(component.error()).toContain('Changing gap violated');
|
||||
});
|
||||
});
|
||||
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal file
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DanceGroup, Show } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-schedule-editor',
|
||||
imports: [FormsModule, DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame
|
||||
[title]="show().name + ' — indeling'"
|
||||
[busy]="busy()"
|
||||
(dismissed)="closed.emit()"
|
||||
>
|
||||
<p>
|
||||
{{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · preferredDayParts =
|
||||
{{ show().preferredDayParts }}
|
||||
</p>
|
||||
<p>
|
||||
Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een
|
||||
onvolledige indeling als concept opslaan.
|
||||
</p>
|
||||
@if (error()) {
|
||||
<p role="alert" class="alert error">{{ error() }}</p>
|
||||
}
|
||||
@if (dirty()) {
|
||||
<p role="status">
|
||||
Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren.
|
||||
</p>
|
||||
}
|
||||
@for (violation of current()?.violations ?? []; track $index) {
|
||||
<p class="alert error">{{ violation }}</p>
|
||||
}
|
||||
<fieldset [disabled]="busy()">
|
||||
@for (part of show().dayParts; track $index; let p = $index) {
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens)
|
||||
</legend>
|
||||
<ol>
|
||||
@for (id of assignments[p]; track $index; let i = $index) {
|
||||
<li>
|
||||
{{ name(id) }}
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="i === 0"
|
||||
(click)="reorder(p, i, -1)"
|
||||
[attr.aria-label]="name(id) + ' omhoog'"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="i === assignments[p].length - 1"
|
||||
(click)="reorder(p, i, 1)"
|
||||
[attr.aria-label]="name(id) + ' omlaag'"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<label
|
||||
>Verplaatsen naar
|
||||
<select [ngModel]="p" (ngModelChange)="move(p, i, +$event)">
|
||||
@for (target of show().dayParts; track $index; let t = $index) {
|
||||
<option [ngValue]="t">{{ target.date }} · {{ target.type }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" (click)="remove(p, i)">Optreden verwijderen</button>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
<label
|
||||
>Groep toevoegen
|
||||
<select [ngModel]="null" (ngModelChange)="add(p, +$event)">
|
||||
<option [ngValue]="null">Kies een groep</option>
|
||||
@for (group of groups(); track group.id) {
|
||||
@if (group.dancerIds.length && !assignments[p].includes(group.id)) {
|
||||
<option [ngValue]="group.id">{{ group.category }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
}
|
||||
</fieldset>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="button secondary" [disabled]="busy()" (click)="closed.emit()">
|
||||
Gereed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button primary"
|
||||
[disabled]="busy() || !dirty()"
|
||||
(click)="save()"
|
||||
>
|
||||
Concept opslaan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button primary"
|
||||
[disabled]="busy() || dirty() || current()?.finalized || !!current()?.violations?.length"
|
||||
(click)="finalize()"
|
||||
>
|
||||
Definitief maken
|
||||
</button>
|
||||
</div>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class ScheduleEditor implements OnInit {
|
||||
readonly show = input.required<Show>();
|
||||
readonly groups = input.required<DanceGroup[]>();
|
||||
readonly schoolId = input.required<number>();
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<void>();
|
||||
readonly busy = signal(false);
|
||||
readonly dirty = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly current = signal<Show | null>(null);
|
||||
private readonly api = inject(SchoolApi);
|
||||
assignments: number[][] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.current.set(this.show());
|
||||
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
|
||||
}
|
||||
name(id: number): string {
|
||||
return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`;
|
||||
}
|
||||
reorder(p: number, i: number, delta: number): void {
|
||||
const list = this.assignments[p];
|
||||
[list[i], list[i + delta]] = [list[i + delta], list[i]];
|
||||
this.dirty.set(true);
|
||||
}
|
||||
move(p: number, i: number, target: number): void {
|
||||
if (target === p || this.assignments[target].includes(this.assignments[p][i])) return;
|
||||
this.assignments[target].push(this.assignments[p].splice(i, 1)[0]);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
add(p: number, id: number): void {
|
||||
if (!id || this.assignments[p].includes(id)) return;
|
||||
this.assignments[p].push(id);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
remove(p: number, i: number): void {
|
||||
this.assignments[p].splice(i, 1);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
async save(): Promise<void> {
|
||||
await this.perform(false);
|
||||
}
|
||||
async finalize(): Promise<void> {
|
||||
await this.perform(true);
|
||||
}
|
||||
private async perform(finalize: boolean): Promise<void> {
|
||||
if (this.busy()) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
finalize
|
||||
? this.api.finalize(this.schoolId(), this.show().id)
|
||||
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
|
||||
);
|
||||
this.current.set(result);
|
||||
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
|
||||
this.dirty.set(false);
|
||||
this.saved.emit();
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ public class Ci {
|
||||
case "install-runner" ->
|
||||
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
|
||||
case "test" -> CiTests.run();
|
||||
case "deploy", "rollback" -> Release.execute(root, env, args[0]);
|
||||
case "release-test" -> ReleaseTests.run();
|
||||
case "configure-deployment" -> ReleaseSetup.configure(root, env);
|
||||
default -> throw new IllegalArgumentException(usage());
|
||||
}
|
||||
} catch (Commands.Failure e) {
|
||||
@@ -54,6 +57,6 @@ public class Ci {
|
||||
|
||||
static String usage() {
|
||||
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
|
||||
+ " <path>|install-runner [--prepare-only]";
|
||||
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ class Pipeline {
|
||||
run("node", "--version");
|
||||
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
|
||||
CiTests.run();
|
||||
ReleaseTests.run();
|
||||
Path frontend = root.resolve("agiledancers-fe");
|
||||
verifyFrontend();
|
||||
String platform = env.get("TARGET_PLATFORM");
|
||||
|
||||
234
ci/Release.java
Normal file
234
ci/Release.java
Normal file
@@ -0,0 +1,234 @@
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Manual release coordinator. All mutation decisions are made under the NAS lock. */
|
||||
class Release {
|
||||
final Path root, artifacts;
|
||||
final Map<String, String> env;
|
||||
final ReleaseApi api;
|
||||
final ReleaseRemote remote;
|
||||
final String mode, increment, retry, pinned, attempt;
|
||||
ReleaseState.Deployment selected;
|
||||
boolean locked, pending;
|
||||
|
||||
static void execute(Path root, Map<String, String> env, String operation) throws Exception {
|
||||
validate(env, operation);
|
||||
String attempt = "release-" + env.get("CI_RUN_ID") + "-" + UUID.randomUUID();
|
||||
Path artifacts = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(artifacts);
|
||||
var commands = new Commands(Duration.ofMinutes(15));
|
||||
commands.log = artifacts.resolve(attempt + ".log");
|
||||
Files.writeString(commands.log, "");
|
||||
try (var remote = new ReleaseRemote(root, env, commands, attempt)) {
|
||||
new Release(root, env, new ReleaseApi(env), remote, operation, attempt).run();
|
||||
}
|
||||
}
|
||||
|
||||
static void validate(Map<String, String> env, String operation) {
|
||||
Pipeline.check("refs/heads/main".equals(env.get("CI_REF")), "Production workflows must run from main");
|
||||
Pipeline.check(
|
||||
"workflow_dispatch".equals(env.get("CI_EVENT")), "Production requires a manual workflow dispatch");
|
||||
Pipeline.check(Setup.required(env, "CI_RUN_ID").matches("[0-9]+"), "Invalid Gitea run ID");
|
||||
ReleaseApi.sha(env.get("CI_COMMIT"));
|
||||
Pipeline.check(List.of("deploy", "rollback").contains(operation), "Unknown release operation");
|
||||
if (operation.equals("rollback")) return;
|
||||
String mode = Setup.required(env, "RELEASE_MODE");
|
||||
String increment = env.getOrDefault("RELEASE_INCREMENT", "");
|
||||
String retry = env.getOrDefault("RELEASE_VERSION", "");
|
||||
if (mode.equals("new")) {
|
||||
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
|
||||
Pipeline.check(retry.isBlank(), "New release cannot specify an existing version");
|
||||
} else {
|
||||
Pipeline.check(mode.equals("retry"), "Choose new or retry release mode");
|
||||
ReleaseVersion.parse(retry);
|
||||
// Gitea shows all inputs; the new-release increment is ignored for retries.
|
||||
}
|
||||
}
|
||||
|
||||
Release(Path root, Map<String, String> env, ReleaseApi api, ReleaseRemote remote, String operation, String attempt)
|
||||
throws Exception {
|
||||
this.root = root;
|
||||
this.env = env;
|
||||
this.api = api;
|
||||
this.remote = remote;
|
||||
this.attempt = attempt;
|
||||
artifacts = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(artifacts);
|
||||
mode = operation.equals("rollback") ? "rollback" : env.get("RELEASE_MODE");
|
||||
increment = env.getOrDefault("RELEASE_INCREMENT", "");
|
||||
retry = env.getOrDefault("RELEASE_VERSION", "");
|
||||
pinned = ReleaseApi.sha(env.get("CI_COMMIT"));
|
||||
}
|
||||
|
||||
void run() throws Exception {
|
||||
String outcome = "rejected";
|
||||
try {
|
||||
remote.acquire();
|
||||
locked = true;
|
||||
remote.call("preflight", null);
|
||||
ReleaseState before = ReleaseState.parse(remote.call("state", null));
|
||||
selected = select(before);
|
||||
System.out.println("Selected " + selected.version() + " at " + selected.sha());
|
||||
// Validate the recovery pair before any replacement too.
|
||||
if (before.current() != null) ensure(before.current());
|
||||
if (mode.equals("new")) {
|
||||
api.create(selected.version(), selected.sha(), attempt);
|
||||
Pipeline.check(
|
||||
selected.sha().equals(api.head()),
|
||||
"Main advanced; release tagged but production unchanged. Retry explicitly.");
|
||||
}
|
||||
var journal = new LinkedHashMap<String, Object>();
|
||||
journal.put("attempt", attempt);
|
||||
journal.put("mode", mode);
|
||||
journal.put("candidate", selected.json());
|
||||
journal.put("before", Json.parse(before.current() == null ? "{}" : before.json()));
|
||||
remote.call("pending", Json.write(journal));
|
||||
pending = true;
|
||||
outcome = "replacement-started";
|
||||
try {
|
||||
remote.call("apply", null, selected.directory());
|
||||
} catch (Exception failure) {
|
||||
diagnose(selected);
|
||||
outcome = recover(before);
|
||||
throw new IllegalArgumentException(
|
||||
"Deployment failed: " + outcome + ". See deployment diagnostics and recovery runbook.");
|
||||
}
|
||||
// A lost reply here is ambiguous: retain journal and lock, never guess
|
||||
// whether a state commit happened and launch an overlapping recovery.
|
||||
outcome = "healthy-state-commit-pending";
|
||||
remote.call("commit", before.success(selected).json());
|
||||
outcome = "success";
|
||||
record(outcome);
|
||||
remote.call("finish", null);
|
||||
pending = false;
|
||||
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
||||
} finally {
|
||||
try {
|
||||
record(outcome);
|
||||
} finally {
|
||||
if (locked && !pending) remote.release();
|
||||
else if (locked)
|
||||
System.err.println("NAS lock and pending journal retained. Follow docs/production-deployment.md for"
|
||||
+ " recovery.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseState.Deployment select(ReleaseState before) throws Exception {
|
||||
if (mode.equals("rollback")) {
|
||||
Pipeline.check(before.previous() != null, "No previous successful production release is available");
|
||||
ensure(before.previous());
|
||||
return before.previous();
|
||||
}
|
||||
List<ReleaseApi.Tag> tags = api.tags();
|
||||
ReleaseVersion version;
|
||||
String commit;
|
||||
if (mode.equals("new")) {
|
||||
Pipeline.check(
|
||||
pinned.equals(api.head()), "Main advanced since this dispatch; start a new release workflow");
|
||||
Pipeline.check(
|
||||
tags.stream().noneMatch(t -> t.sha().equals(pinned)),
|
||||
"This commit already has a release tag; use retry or rollback");
|
||||
version = ReleaseVersion.next(
|
||||
tags.stream().map(ReleaseApi.Tag::version).toList(), increment);
|
||||
commit = pinned;
|
||||
} else {
|
||||
version = ReleaseVersion.parse(retry);
|
||||
var tag = tags.stream()
|
||||
.filter(t -> t.version().equals(version))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Retry release tag does not exist"));
|
||||
api.annotated(tag);
|
||||
commit = tag.sha();
|
||||
}
|
||||
Pipeline.check(
|
||||
before.highest() == null || version.compareTo(before.highest()) >= (mode.equals("new") ? 1 : 0),
|
||||
"A newer release has already succeeded; use explicit rollback for older versions");
|
||||
String build = api.verified(commit);
|
||||
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
|
||||
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
|
||||
return new ReleaseState.Deployment(
|
||||
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
|
||||
}
|
||||
|
||||
String prefix() {
|
||||
var uri = URI.create(api.server);
|
||||
String authority = uri.getRawAuthority();
|
||||
String registry = env.getOrDefault("DEPLOY_REGISTRY", "").strip();
|
||||
if (registry.isEmpty()) registry = authority;
|
||||
int port = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
|
||||
Pipeline.check(
|
||||
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
|
||||
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
|
||||
String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT);
|
||||
return registry + "/" + owner + "/agiledancers-";
|
||||
}
|
||||
|
||||
void ensure(ReleaseState.Deployment release) throws Exception {
|
||||
remote.call(
|
||||
"ensure",
|
||||
null,
|
||||
release.directory(),
|
||||
release.version().toString(),
|
||||
release.sha(),
|
||||
release.backend(),
|
||||
release.frontend());
|
||||
}
|
||||
|
||||
String digest(String response, String role) {
|
||||
String value = response.lines()
|
||||
.filter(line -> line.startsWith("S6_" + role + "="))
|
||||
.map(line -> line.substring(("S6_" + role + "=").length()))
|
||||
.reduce((a, b) -> {
|
||||
throw new IllegalArgumentException("Ambiguous prepared image digest");
|
||||
})
|
||||
.orElseThrow(() -> new IllegalArgumentException("Missing prepared image digest"));
|
||||
Pipeline.check(
|
||||
value.startsWith(prefix() + role.toLowerCase(java.util.Locale.ROOT) + "@sha256:"),
|
||||
"Unexpected image repository");
|
||||
return value;
|
||||
}
|
||||
|
||||
String recover(ReleaseState before) throws Exception {
|
||||
if (before.current() == null) return "first-release-failed-manual-recovery-required";
|
||||
try {
|
||||
remote.call("apply", null, before.current().directory());
|
||||
} catch (Exception failure) {
|
||||
diagnose(before.current());
|
||||
return "recovery-failed-manual-recovery-required";
|
||||
}
|
||||
record("failed-previous-release-restored");
|
||||
remote.call("finish", null);
|
||||
pending = false;
|
||||
return "previous-release-restored";
|
||||
}
|
||||
|
||||
void diagnose(ReleaseState.Deployment release) {
|
||||
try {
|
||||
Files.writeString(
|
||||
artifacts.resolve(attempt + "-" + release.directory() + "-containers.log"),
|
||||
remote.call("diagnose", null, release.directory()));
|
||||
} catch (Exception ignored) {
|
||||
System.err.println("Container diagnostics unavailable; use the NAS recovery runbook");
|
||||
}
|
||||
}
|
||||
|
||||
void record(String outcome) throws Exception {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("attempt", attempt);
|
||||
data.put("mode", mode);
|
||||
data.put("outcome", outcome);
|
||||
data.put("time", Instant.now().toString());
|
||||
data.put("release", selected == null ? null : selected.json());
|
||||
String json = Json.write(data) + "\n";
|
||||
Files.writeString(artifacts.resolve(attempt + ".json"), json);
|
||||
if (locked) remote.call("event", json);
|
||||
}
|
||||
}
|
||||
124
ci/ReleaseApi.java
Normal file
124
ci/ReleaseApi.java
Normal file
@@ -0,0 +1,124 @@
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Gitea 1.25 API adapter. Never include response bodies or tokens in errors. */
|
||||
class ReleaseApi {
|
||||
record Tag(ReleaseVersion version, String sha, String object) {}
|
||||
|
||||
final String server, token, base;
|
||||
|
||||
ReleaseApi(Map<String, String> env) {
|
||||
server = Setup.required(env, "GITEA_SERVER_URL");
|
||||
var uri = URI.create(server);
|
||||
Pipeline.check(
|
||||
List.of("http", "https").contains(uri.getScheme())
|
||||
&& uri.getUserInfo() == null
|
||||
&& uri.getQuery() == null
|
||||
&& uri.getFragment() == null
|
||||
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
||||
"Gitea must use a server-root URL");
|
||||
String repository = Setup.required(env, "CI_REPOSITORY");
|
||||
Pipeline.check(repository.matches("[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+"), "Invalid repository name");
|
||||
base = "/api/v1/repos/" + repository;
|
||||
token = Setup.required(env, "RELEASE_TOKEN");
|
||||
}
|
||||
|
||||
Object request(String method, String path, Object body) throws Exception {
|
||||
return Setup.api(server, token, method, base + path, body);
|
||||
}
|
||||
|
||||
String head() throws Exception {
|
||||
return sha(
|
||||
Json.object(Json.object(request("GET", "/branches/main", null)).get("commit"))
|
||||
.get("id"));
|
||||
}
|
||||
|
||||
static String sha(Object value) {
|
||||
String result = Objects.toString(value, "");
|
||||
Pipeline.check(result.matches("[0-9a-f]{40}"), "Invalid release commit SHA");
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Tag> tags() throws Exception {
|
||||
List<Tag> tags = new ArrayList<>();
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (int page = 1; ; page++) {
|
||||
var entries = (List<?>) request("GET", "/tags?limit=50&page=" + page, null);
|
||||
if (entries.isEmpty()) return tags;
|
||||
for (Object entry : entries) {
|
||||
var tag = Json.object(entry);
|
||||
String name = Objects.toString(tag.get("name"), "");
|
||||
Pipeline.check(
|
||||
seen.add(name),
|
||||
"Remote tag pagination repeated a tag; cannot establish a safe version baseline");
|
||||
// Unknown version-looking tags require explicit reconciliation, not a second baseline.
|
||||
if (!name.matches("v?[0-9].*")) continue;
|
||||
ReleaseVersion version = ReleaseVersion.parse(name);
|
||||
String commit = sha(Json.object(tag.get("commit")).get("sha"));
|
||||
String object = sha(tag.get("id"));
|
||||
tags.add(new Tag(version, commit, object));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void annotated(Tag tag) throws Exception {
|
||||
var data = Json.object(request("GET", "/git/tags/" + tag.object(), null));
|
||||
Pipeline.check(
|
||||
tag.version().toString().equals(data.get("tag"))
|
||||
&& tag.sha().equals(Json.object(data.get("object")).get("sha")),
|
||||
"Release tag must be annotated and point directly to its recorded commit");
|
||||
}
|
||||
|
||||
void create(ReleaseVersion version, String commit, String run) throws Exception {
|
||||
request(
|
||||
"POST",
|
||||
"/tags",
|
||||
Map.of(
|
||||
"tag_name",
|
||||
version.toString(),
|
||||
"target",
|
||||
commit,
|
||||
"message",
|
||||
"Agiledancers " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
|
||||
Tag tag = tags().stream()
|
||||
.filter(t -> t.version().equals(version))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Pipeline.check(tag.sha().equals(commit), "Created release tag does not match selected commit");
|
||||
annotated(tag);
|
||||
}
|
||||
|
||||
String verified(String commit) throws Exception {
|
||||
// A successful build.yaml main-push run includes verification AND successful image publication.
|
||||
// Do not accept another workflow, a branch build, or merely an existing image/tag.
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (int page = 1; ; page++) {
|
||||
var response = Json.object(request(
|
||||
"GET",
|
||||
"/actions/runs?head_sha=" + commit + "&event=push&branch=main&status=success&limit=50&page=" + page,
|
||||
null));
|
||||
var runs = (List<?>) response.get("workflow_runs");
|
||||
if (runs.isEmpty()) break;
|
||||
boolean fresh = false;
|
||||
for (Object entry : runs) {
|
||||
var run = Json.object(entry);
|
||||
fresh |= seen.add(Objects.toString(run.get("id")));
|
||||
if (commit.equals(run.get("head_sha"))
|
||||
&& "push".equals(run.get("event"))
|
||||
&& List.of("main", "refs/heads/main").contains(Objects.toString(run.get("head_branch")))
|
||||
&& "completed".equals(run.get("status"))
|
||||
&& "success".equals(run.get("conclusion"))
|
||||
&& "build.yaml@refs/heads/main".equals(run.get("path"))) {
|
||||
return Objects.toString(run.get("id"));
|
||||
}
|
||||
}
|
||||
// Older Gitea installations may ignore some list filters/pagination.
|
||||
// Validate each entry locally and stop if a page makes no progress.
|
||||
if (!fresh) break;
|
||||
}
|
||||
throw new IllegalArgumentException("No successful build.yaml main-push run verifies and publishes this SHA");
|
||||
}
|
||||
}
|
||||
119
ci/ReleaseRemote.java
Normal file
119
ci/ReleaseRemote.java
Normal file
@@ -0,0 +1,119 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Argument-safe SSH adapter; server-side mkdir lock is shared by every runner. */
|
||||
class ReleaseRemote implements AutoCloseable {
|
||||
final Path root, credentials;
|
||||
final Map<String, String> env;
|
||||
final Commands commands;
|
||||
final String owner, directory, host, user, port, script;
|
||||
|
||||
ReleaseRemote(Path root, Map<String, String> env, Commands commands, String owner) throws Exception {
|
||||
this.root = root;
|
||||
this.env = env;
|
||||
this.commands = commands;
|
||||
this.owner = owner;
|
||||
directory = Setup.required(env, "DEPLOY_DIRECTORY");
|
||||
host = Setup.required(env, "DEPLOY_HOST");
|
||||
user = Setup.required(env, "DEPLOY_USER");
|
||||
port = env.getOrDefault("DEPLOY_SSH_PORT", "22");
|
||||
Pipeline.check(
|
||||
directory.matches("/[a-zA-Z0-9_./-]+") && !directory.contains("..") && !directory.equals("/"),
|
||||
"Use an absolute dedicated deployment directory without spaces or parent traversal");
|
||||
Pipeline.check(
|
||||
host.matches("[a-zA-Z0-9][a-zA-Z0-9.-]*")
|
||||
&& user.matches("[a-zA-Z0-9_][a-zA-Z0-9_-]*")
|
||||
&& port.matches("[0-9]{1,5}")
|
||||
&& Integer.parseInt(port) > 0
|
||||
&& Integer.parseInt(port) <= 65535,
|
||||
"Invalid deployment SSH host, user or port");
|
||||
script = Files.readString(root.resolve("deploy/remote.sh"));
|
||||
credentials = Files.createTempDirectory("agiledancers-deploy-ssh-");
|
||||
try {
|
||||
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
|
||||
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
|
||||
} catch (Exception e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
void writePrivate(String name, String value) throws Exception {
|
||||
Path file = credentials.resolve(name);
|
||||
Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.writeString(file, value);
|
||||
}
|
||||
|
||||
static String quote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
String call(String action, String input, String... arguments) throws Exception {
|
||||
int seconds = switch (action) {
|
||||
case "apply" -> 300;
|
||||
case "prepare", "ensure" -> 600;
|
||||
default -> 60;
|
||||
};
|
||||
var parameters = new ArrayList<>(List.of(action, directory, owner));
|
||||
parameters.addAll(List.of(arguments));
|
||||
return executeScript(script, input, seconds, parameters);
|
||||
}
|
||||
|
||||
String executeScript(String source, String input, int seconds, List<String> parameters) throws Exception {
|
||||
String remote = "timeout -k 5 " + seconds + " sh -c " + quote(source) + " --";
|
||||
for (String argument : parameters) remote += " " + quote(argument);
|
||||
var args = new ArrayList<>(List.of(
|
||||
"ssh",
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"IdentityAgent=none",
|
||||
"-o",
|
||||
"ForwardAgent=no",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ServerAliveInterval=10",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-o",
|
||||
"UserKnownHostsFile=" + credentials.resolve("known_hosts"),
|
||||
"-o",
|
||||
"GlobalKnownHostsFile=/dev/null",
|
||||
"-i",
|
||||
credentials.resolve("identity").toString(),
|
||||
"-p",
|
||||
port,
|
||||
user + "@" + host,
|
||||
remote));
|
||||
commands.renew(Duration.ofSeconds(seconds + 30));
|
||||
return commands.execute(args, root, env, input, false).checked();
|
||||
}
|
||||
|
||||
void acquire() throws Exception {
|
||||
call("lock", null);
|
||||
}
|
||||
|
||||
void release() throws Exception {
|
||||
call("unlock", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
Pipeline.deleteTree(credentials);
|
||||
}
|
||||
}
|
||||
107
ci/ReleaseSetup.java
Normal file
107
ci/ReleaseSetup.java
Normal file
@@ -0,0 +1,107 @@
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** One-time provisioning; secrets stay in owner-only files or Gitea secret storage. */
|
||||
class ReleaseSetup {
|
||||
static void configure(Path root, Map<String, String> input) throws Exception {
|
||||
var env = new HashMap<>(input);
|
||||
env.putIfAbsent("GITEA_SERVER_URL", "http://192.168.2.28:3000");
|
||||
env.putIfAbsent("CI_REPOSITORY", "wanjakrah/agiledancers");
|
||||
env.putIfAbsent("DEPLOY_HOST", "192.168.2.28");
|
||||
env.putIfAbsent("DEPLOY_USER", "agiledancers-deploy");
|
||||
env.putIfAbsent("DEPLOY_SSH_PORT", "22");
|
||||
env.putIfAbsent("DEPLOY_DIRECTORY", "/volume1/docker/agiledancers-production");
|
||||
env.putIfAbsent("DEPLOY_BIND_IP", "192.168.2.28");
|
||||
env.putIfAbsent("DEPLOY_FRONTEND_PORT", "8088");
|
||||
env.putIfAbsent("DEPLOY_REGISTRY", "127.0.0.1:3000");
|
||||
Path config = Setup.home().resolve(".config/agiledancers-runner");
|
||||
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
|
||||
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("s6-release-token").toString());
|
||||
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("s6-pull-token").toString());
|
||||
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("s6-deploy-key").toString());
|
||||
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
||||
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
|
||||
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE"));
|
||||
env.put("DEPLOY_SSH_KEY", Setup.secret(env, "DEPLOY_SSH_KEY_FILE"));
|
||||
String bind = env.get("DEPLOY_BIND_IP"), port = env.get("DEPLOY_FRONTEND_PORT");
|
||||
Pipeline.check(
|
||||
bind.matches("[0-9.]+")
|
||||
&& port.matches("[0-9]{4,5}")
|
||||
&& Integer.parseInt(port) >= 1024
|
||||
&& Integer.parseInt(port) <= 65535,
|
||||
"Invalid private bind address or port");
|
||||
var commands = new Commands(Duration.ofMinutes(15));
|
||||
Path diagnostics = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(diagnostics);
|
||||
commands.log = diagnostics.resolve("setup.log");
|
||||
Files.writeString(commands.log, "");
|
||||
String host = env.get("DEPLOY_HOST");
|
||||
String lookup = env.get("DEPLOY_SSH_PORT").equals("22") ? host : "[" + host + "]:" + env.get("DEPLOY_SSH_PORT");
|
||||
env.put(
|
||||
"DEPLOY_KNOWN_HOSTS",
|
||||
commands.execute(
|
||||
List.of(
|
||||
"ssh-keygen",
|
||||
"-F",
|
||||
lookup,
|
||||
"-f",
|
||||
Setup.home().resolve(".ssh/known_hosts").toString()),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked());
|
||||
Pipeline.check(!env.get("DEPLOY_KNOWN_HOSTS").isBlank(), "A previously trusted NAS hostkey is required");
|
||||
var api = new ReleaseApi(env);
|
||||
api.verified(api.head());
|
||||
var uri = URI.create(api.server);
|
||||
String registry = env.get("DEPLOY_REGISTRY");
|
||||
int registryPort = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
|
||||
Pipeline.check(
|
||||
registry.equals(uri.getRawAuthority()) || registry.equals("127.0.0.1:" + registryPort),
|
||||
"Deployment registry must be the Gitea registry or its NAS loopback alias");
|
||||
String registryUser =
|
||||
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
|
||||
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
|
||||
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
|
||||
System.out.println(remote.executeScript(
|
||||
Files.readString(root.resolve("deploy/provision.sh")),
|
||||
pull + "\n",
|
||||
300,
|
||||
List.of(remote.directory, registryUser, registry, bind, port)));
|
||||
remote.acquire();
|
||||
try {
|
||||
remote.call("preflight", null);
|
||||
} finally {
|
||||
remote.release();
|
||||
}
|
||||
}
|
||||
// Provision credentials only after successful NAS login and release-read verification.
|
||||
for (String secret : List.of("RELEASE_TOKEN", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS"))
|
||||
Setup.api(
|
||||
api.server, admin, "PUT", api.base + "/actions/secrets/" + secret, Map.of("data", env.get(secret)));
|
||||
for (String variable :
|
||||
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
|
||||
variable(api, admin, variable, env.get(variable));
|
||||
System.out.println(
|
||||
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
|
||||
}
|
||||
|
||||
static void variable(ReleaseApi api, String admin, String name, String value) throws Exception {
|
||||
String path = api.base + "/actions/variables/" + name;
|
||||
boolean exists;
|
||||
try {
|
||||
Setup.api(api.server, admin, "GET", path, null);
|
||||
exists = true;
|
||||
} catch (Setup.ApiFailure error) {
|
||||
if (error.status != 404) throw error;
|
||||
exists = false;
|
||||
}
|
||||
Setup.api(api.server, admin, exists ? "PUT" : "POST", path, Map.of("value", value));
|
||||
}
|
||||
}
|
||||
197
ci/ReleaseShellTests.java
Normal file
197
ci/ReleaseShellTests.java
Normal file
@@ -0,0 +1,197 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Runs the actual NAS script with disposable command adapters, no Docker daemon. */
|
||||
class ReleaseShellTests {
|
||||
static void run() throws Exception {
|
||||
ReleaseTests.test("actual NAS script prepares digest images and checks the published endpoint", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
f.call("pending", "{}", "one").checked();
|
||||
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
|
||||
String calls = Files.readString(f.directory.resolve("calls"));
|
||||
CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready"));
|
||||
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/dance-schools"));
|
||||
CiTests.equal(false, calls.contains(" down"));
|
||||
CiTests.equal(false, calls.contains("prune"));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("WRONG_REVISION", "1");
|
||||
CiTests.failure(f::prepare);
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects unavailable image before replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("FAIL_PULL", "1");
|
||||
CiTests.failure(f::prepare);
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects a changed retained release record", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
CiTests.failure(() -> f.call(
|
||||
"ensure", null, "one", "one", "v9.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
|
||||
.checked());
|
||||
f.call("ensure", null, "one", "one", "v1.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
|
||||
.checked();
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS replacement timeout is bounded and leaves recovery journal", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
f.call("pending", "{}", "one").checked();
|
||||
f.env.put("FAIL_HEALTH", "1");
|
||||
CiTests.equal(1, f.call("apply", null, "one", "one", "1").code());
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("pending.json")));
|
||||
// Termination releases only the operation fence, not the deployment lock.
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("deploy.lock/operation")));
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("deploy.lock/owner")));
|
||||
f.env.remove("FAIL_HEALTH");
|
||||
f.call("apply", null, "one", "one").checked();
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script fences overlapping remote replacements", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
Files.createDirectory(f.directory.resolve("deploy.lock/operation"));
|
||||
CiTests.failure(() -> f.call("apply", null, "one", "one").checked());
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script stores state atomically and archives resolved journal", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.call("lock", null, "one").checked();
|
||||
f.call("pending", "{\"attempt\":\"one\"}", "one").checked();
|
||||
f.call("commit", "{\"current\":\"one\"}", "one").checked();
|
||||
CiTests.equal(
|
||||
"{\"current\":\"one\"}", f.call("state", null, "one").checked());
|
||||
f.call("event", "{\"outcome\":\"success\"}", "one").checked();
|
||||
f.call("finish", null, "one").checked();
|
||||
f.call("unlock", null, "one").checked();
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("history/one.json")));
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("pending.json")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static class Fixture implements AutoCloseable {
|
||||
final Path directory = Files.createTempDirectory("nas-script-test-");
|
||||
final Map<String, String> env = new HashMap<>(System.getenv());
|
||||
final String script = Files.readString(Path.of("deploy/remote.sh"));
|
||||
final Commands commands = new Commands(Duration.ofMinutes(1));
|
||||
|
||||
Fixture() throws Exception {
|
||||
Files.createDirectory(directory.resolve("bin"));
|
||||
executable("docker", DOCKER);
|
||||
executable(
|
||||
"curl",
|
||||
"#!/bin/sh\nprintf 'curl %s\\n' \"$*\" >> \"$FIXTURE/calls\"\n[ \"${FAIL_HEALTH:-0}\" = 0 ]\n");
|
||||
env.put("PATH", directory.resolve("bin") + ":" + env.get("PATH"));
|
||||
env.put("FIXTURE", directory.toString());
|
||||
Files.createDirectory(directory.resolve("docker-auth"));
|
||||
Files.writeString(directory.resolve("docker-auth/config.json"), "{}");
|
||||
Files.writeString(
|
||||
directory.resolve("production.env"),
|
||||
"BIND_IP=127.0.0.1\nFRONTEND_PORT=8088\nDOCKER_SUDO=0\nDOCKER_BIN="
|
||||
+ directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth")
|
||||
+ "\n");
|
||||
}
|
||||
|
||||
void executable(String name, String body) throws Exception {
|
||||
Path path = directory.resolve("bin").resolve(name);
|
||||
Files.writeString(path, body);
|
||||
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
|
||||
}
|
||||
|
||||
Commands.Result call(String action, String input, String owner, String... args) throws Exception {
|
||||
var command = new ArrayList<>(
|
||||
List.of("timeout", "-k", "1", "10", "sh", "-c", script, "--", action, directory.toString(), owner));
|
||||
command.addAll(List.of(args));
|
||||
commands.renew(Duration.ofSeconds(15));
|
||||
return commands.execute(command, directory, env, input, false);
|
||||
}
|
||||
|
||||
String backend() {
|
||||
return "nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64);
|
||||
}
|
||||
|
||||
String frontend() {
|
||||
return "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64);
|
||||
}
|
||||
|
||||
void prepare() throws Exception {
|
||||
call("lock", null, "one").checked();
|
||||
call("preflight", null, "one").checked();
|
||||
call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one")
|
||||
.checked();
|
||||
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-")
|
||||
.checked();
|
||||
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
|
||||
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
Pipeline.deleteTree(directory);
|
||||
}
|
||||
}
|
||||
|
||||
static final String DOCKER = """
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
printf 'docker %s\n' "$*" >> "$FIXTURE/calls"
|
||||
[ "$1" != --config ] || shift 2
|
||||
case "$1" in
|
||||
info) echo linux/x86_64;;
|
||||
pull) [ "${FAIL_PULL:-0}" = 0 ];;
|
||||
image)
|
||||
format=$4; image=$5
|
||||
case "$image" in *backend*) role=backend; digit=1;; *) role=frontend; digit=2;; esac
|
||||
case "$format" in
|
||||
*revision*)
|
||||
if [ "${WRONG_REVISION:-0}" = 1 ]; then echo wrong; else printf '%040d\n' 0 | tr 0 a; fi;;
|
||||
*Architecture*) echo linux/amd64;;
|
||||
*RepoDigests*)
|
||||
printf '%s@sha256:' "${image%:*}"; printf '%064d\n' 0 | tr 0 "$digit";;
|
||||
*Id*) echo "sha256:$role";;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
compose)
|
||||
shift
|
||||
[ "$1" != version ] || exit 0
|
||||
shift 4
|
||||
[ "$1" = --env-file ] || exit 1
|
||||
file=$2
|
||||
shift 4
|
||||
case "$1" in
|
||||
config) test -f "$file";;
|
||||
up) cp "$file" "$FIXTURE/live.env";;
|
||||
ps) eval 'role=${'"$#"'}'; echo "$role";;
|
||||
logs) echo 'sample container diagnostics';;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
inspect)
|
||||
. "$FIXTURE/live.env"
|
||||
format=$3; id=$4
|
||||
case "$format" in
|
||||
*State.Running*) echo true;;
|
||||
*Image*) echo "sha256:$id";;
|
||||
*revision*) echo "$RELEASE_SHA";;
|
||||
*release*) echo "$RELEASE_VERSION";;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
exec) [ "${FAIL_HEALTH:-0}" = 0 ];;
|
||||
*) exit 1;;
|
||||
esac
|
||||
""";
|
||||
}
|
||||
80
ci/ReleaseState.java
Normal file
80
ci/ReleaseState.java
Normal file
@@ -0,0 +1,80 @@
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Successful deployment state is separate from immutable allocated version tags. */
|
||||
record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment previous, ReleaseVersion highest) {
|
||||
record Deployment(
|
||||
ReleaseVersion version, String sha, String backend, String frontend, String directory, String build) {
|
||||
Deployment {
|
||||
ReleaseApi.sha(sha);
|
||||
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
|
||||
for (String image : new String[] {backend, frontend})
|
||||
Pipeline.check(image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}"), "Invalid retained image digest");
|
||||
}
|
||||
|
||||
Map<String, Object> json() {
|
||||
return Map.of(
|
||||
"version",
|
||||
version.toString(),
|
||||
"sha",
|
||||
sha,
|
||||
"backend",
|
||||
backend,
|
||||
"frontend",
|
||||
frontend,
|
||||
"directory",
|
||||
directory,
|
||||
"build",
|
||||
build);
|
||||
}
|
||||
|
||||
static Deployment parse(Object value) {
|
||||
if (value == null) return null;
|
||||
var data = Json.object(value);
|
||||
return new Deployment(
|
||||
ReleaseVersion.parse(text(data, "version")),
|
||||
text(data, "sha"),
|
||||
text(data, "backend"),
|
||||
text(data, "frontend"),
|
||||
text(data, "directory"),
|
||||
text(data, "build"));
|
||||
}
|
||||
}
|
||||
|
||||
static String text(Map<String, Object> data, String key) {
|
||||
return Objects.toString(data.get(key), "");
|
||||
}
|
||||
|
||||
static ReleaseState parse(String value) {
|
||||
var data = Json.object(Json.parse(value));
|
||||
if (data.isEmpty()) return new ReleaseState(null, null, null);
|
||||
Pipeline.check("1".equals(text(data, "schema")), "Unsupported deployment state schema");
|
||||
Deployment current = Deployment.parse(data.get("current"));
|
||||
Deployment previous = Deployment.parse(data.get("previous"));
|
||||
ReleaseVersion highest = ReleaseVersion.parse(text(data, "highest"));
|
||||
Pipeline.check(
|
||||
current != null
|
||||
&& highest.compareTo(current.version()) >= 0
|
||||
&& (previous == null || highest.compareTo(previous.version()) >= 0),
|
||||
"Inconsistent successful release state");
|
||||
return new ReleaseState(current, previous, highest);
|
||||
}
|
||||
|
||||
String json() {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("schema", "1");
|
||||
data.put("current", current == null ? null : current.json());
|
||||
data.put("previous", previous == null ? null : previous.json());
|
||||
data.put("highest", highest == null ? null : highest.toString());
|
||||
return Json.write(data) + "\n";
|
||||
}
|
||||
|
||||
ReleaseState success(Deployment selected) {
|
||||
ReleaseVersion watermark =
|
||||
highest == null || selected.version().compareTo(highest) > 0 ? selected.version() : highest;
|
||||
// A redeploy of the same release must not discard the distinct rollback target.
|
||||
Deployment fallback = current != null && current.version().equals(selected.version()) ? previous : current;
|
||||
return new ReleaseState(selected, fallback, watermark);
|
||||
}
|
||||
}
|
||||
549
ci/ReleaseTests.java
Normal file
549
ci/ReleaseTests.java
Normal file
@@ -0,0 +1,549 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Release state-machine and real POSIX remote-script tests, without server writes. */
|
||||
class ReleaseTests {
|
||||
static final String SHA = "a".repeat(40), OLD = "b".repeat(40);
|
||||
static int count;
|
||||
|
||||
static void test(String name, CiTests.Test test) throws Exception {
|
||||
test.run();
|
||||
count++;
|
||||
System.out.println("PASS release: " + name);
|
||||
}
|
||||
|
||||
static Map<String, String> environment() {
|
||||
var env = new HashMap<>(System.getenv());
|
||||
env.putAll(Map.of(
|
||||
"CI_REF",
|
||||
"refs/heads/main",
|
||||
"CI_EVENT",
|
||||
"workflow_dispatch",
|
||||
"CI_RUN_ID",
|
||||
"123",
|
||||
"CI_COMMIT",
|
||||
SHA,
|
||||
"RELEASE_MODE",
|
||||
"new",
|
||||
"RELEASE_INCREMENT",
|
||||
"patch",
|
||||
"RELEASE_VERSION",
|
||||
"",
|
||||
"GITEA_SERVER_URL",
|
||||
"http://nas:3000",
|
||||
"CI_REPOSITORY",
|
||||
"owner/agiledancers",
|
||||
"RELEASE_TOKEN",
|
||||
"test"));
|
||||
env.putAll(Map.of(
|
||||
"DEPLOY_HOST",
|
||||
"nas",
|
||||
"DEPLOY_USER",
|
||||
"deploy",
|
||||
"DEPLOY_DIRECTORY",
|
||||
"/tmp/production",
|
||||
"DEPLOY_SSH_KEY",
|
||||
"test-private-key",
|
||||
"DEPLOY_KNOWN_HOSTS",
|
||||
"test-pinned-host"));
|
||||
return env;
|
||||
}
|
||||
|
||||
static ReleaseState.Deployment deployment(String version, String sha, String directory) {
|
||||
return new ReleaseState.Deployment(
|
||||
ReleaseVersion.parse(version),
|
||||
sha,
|
||||
"nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64),
|
||||
"nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64),
|
||||
directory,
|
||||
"10");
|
||||
}
|
||||
|
||||
static ReleaseState prior() {
|
||||
return new ReleaseState(
|
||||
deployment("v1.0.1", OLD, "previous-current"),
|
||||
deployment("v1.0.0", OLD, "previous-old"),
|
||||
ReleaseVersion.parse("v1.0.1"));
|
||||
}
|
||||
|
||||
static void run() throws Exception {
|
||||
count = 0;
|
||||
test("first release and all increments reset the correct components", () -> {
|
||||
CiTests.equal("v1.0.0", ReleaseVersion.next(List.of(), "major").toString());
|
||||
var versions = List.of(ReleaseVersion.parse("v1.2.3"), ReleaseVersion.parse("v1.1.9"));
|
||||
CiTests.equal("v1.2.4", ReleaseVersion.next(versions, "patch").toString());
|
||||
CiTests.equal("v1.3.0", ReleaseVersion.next(versions, "minor").toString());
|
||||
CiTests.equal("v2.0.0", ReleaseVersion.next(versions, "major").toString());
|
||||
CiTests.equal(
|
||||
"v1.0.100000000000000000000",
|
||||
ReleaseVersion.next(List.of(ReleaseVersion.parse("v1.0.99999999999999999999")), "patch")
|
||||
.toString());
|
||||
for (String invalid : List.of("1.0.0", "v01.0.0", "v1.0.0-rc1", "v1.0.0+build", "v1.0", "v-1.0.0"))
|
||||
CiTests.failure(() -> ReleaseVersion.parse(invalid));
|
||||
});
|
||||
test("branch, nonmanual, malformed input and conflicting new-release inputs are rejected", () -> {
|
||||
var env = environment();
|
||||
Release.validate(env, "deploy");
|
||||
env.put("CI_REF", "refs/heads/feature/S6");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
env.put("CI_REF", "refs/heads/main");
|
||||
env.put("CI_EVENT", "push");
|
||||
CiTests.failure(() -> Release.validate(env, "rollback"));
|
||||
env.put("CI_EVENT", "workflow_dispatch");
|
||||
env.put("RELEASE_VERSION", "v1.0.0");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
env.put("RELEASE_MODE", "retry");
|
||||
Release.validate(env, "deploy");
|
||||
env.put("RELEASE_VERSION", "bad");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
});
|
||||
test("successful release stages images before tag, checks main again and commits state", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.release.run();
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(true, f.events.indexOf("prepare") < f.events.indexOf("tag"));
|
||||
CiTests.equal(true, f.events.indexOf("tag") < f.events.lastIndexOf("head"));
|
||||
CiTests.equal(true, f.events.lastIndexOf("head") < f.events.indexOf("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("unverified commit never creates a tag or replaces production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.verified = false;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("missing images prevent tag allocation and replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.failAction = "prepare";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
}
|
||||
});
|
||||
test("a tagged commit cannot receive another new version", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("prepare"));
|
||||
}
|
||||
});
|
||||
test("stale dispatch is rejected before pulling or tagging", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.head = OLD;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("prepare"));
|
||||
}
|
||||
});
|
||||
test("main advancing after tag creation preserves tag without changing production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.advanceAfterTag = true;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(1, f.api.tags.size());
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("unavailable recovery images block replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failAction = "ensure";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
}
|
||||
});
|
||||
test("failed candidate restores prior version and still fails the workflow", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failedDirectories.add("attempt");
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
List.of("apply:attempt", "apply:previous-current"),
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).toList());
|
||||
CiTests.equal(prior(), f.remote.state);
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
CiTests.equal(2, f.api.tags.size());
|
||||
}
|
||||
});
|
||||
test("failed first release retains journal and lock for manual recovery", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.failedDirectories.add("attempt");
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
CiTests.equal(null, f.remote.state.current());
|
||||
}
|
||||
});
|
||||
test("failed automatic recovery retains journal and lock without retry loop", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failedDirectories.addAll(List.of("attempt", "previous-current"));
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
2L,
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).count());
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("ambiguous state commit never launches speculative rollback", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failAction = "commit";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
1L,
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).count());
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("retry keeps existing version and SHA after main advances", () -> {
|
||||
try (var f = new Fixture("retry")) {
|
||||
f.api.head = OLD;
|
||||
f.release.run();
|
||||
CiTests.equal(SHA, f.remote.state.current().sha());
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(true, f.events.contains("annotated"));
|
||||
}
|
||||
});
|
||||
test("retry cannot cross the successful-version high watermark after rollback", () -> {
|
||||
try (var f = new Fixture("retry")) {
|
||||
f.remote.state = prior().success(prior().previous());
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("manual rollback preserves version baseline and creates no tag", () -> {
|
||||
try (var f = new Fixture("rollback")) {
|
||||
f.remote.state = prior();
|
||||
f.release.run();
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal("v1.0.1", f.remote.state.previous().version().toString());
|
||||
CiTests.equal("v1.0.1", f.remote.state.highest().toString());
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
}
|
||||
});
|
||||
test("redeploying current version preserves a distinct rollback target", () -> {
|
||||
var state = prior();
|
||||
CiTests.equal(state.previous(), state.success(state.current()).previous());
|
||||
CiTests.equal(state, ReleaseState.parse(state.json()));
|
||||
});
|
||||
test("NAS lock contention cannot allocate tags or replace production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.locked = true;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
apiTests();
|
||||
shellTests();
|
||||
ReleaseShellTests.run();
|
||||
System.out.println(count + " release behavior tests passed");
|
||||
}
|
||||
|
||||
static class FakeApi extends ReleaseApi {
|
||||
final List<String> events;
|
||||
final List<Tag> tags = new ArrayList<>();
|
||||
String head = SHA;
|
||||
boolean verified = true, advanceAfterTag;
|
||||
|
||||
FakeApi(Map<String, String> env, List<String> events) {
|
||||
super(env);
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
String head() {
|
||||
events.add("head");
|
||||
return head;
|
||||
}
|
||||
|
||||
@Override
|
||||
List<Tag> tags() {
|
||||
return List.copyOf(tags);
|
||||
}
|
||||
|
||||
@Override
|
||||
String verified(String sha) {
|
||||
Pipeline.check(verified, "Unverified test commit");
|
||||
events.add("verified");
|
||||
return "10";
|
||||
}
|
||||
|
||||
@Override
|
||||
void annotated(Tag tag) {
|
||||
events.add("annotated");
|
||||
}
|
||||
|
||||
@Override
|
||||
void create(ReleaseVersion version, String sha, String attempt) {
|
||||
events.add("tag");
|
||||
tags.add(new Tag(version, sha, OLD));
|
||||
if (advanceAfterTag) head = OLD;
|
||||
}
|
||||
}
|
||||
|
||||
static class FakeRemote extends ReleaseRemote {
|
||||
final List<String> events;
|
||||
final List<String> failedDirectories = new ArrayList<>();
|
||||
ReleaseState state = new ReleaseState(null, null, null);
|
||||
boolean locked, pending;
|
||||
String failAction = "";
|
||||
|
||||
FakeRemote(Path root, Map<String, String> env, List<String> events) throws Exception {
|
||||
super(root, env, new Commands(Duration.ofMinutes(1)), "attempt");
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
String call(String action, String input, String... args) {
|
||||
events.add(action.equals("apply") ? action + ":" + args[0] : action);
|
||||
Pipeline.check(!action.equals(failAction), "Injected remote failure");
|
||||
switch (action) {
|
||||
case "lock" -> {
|
||||
Pipeline.check(!locked && !pending, "Locked");
|
||||
locked = true;
|
||||
}
|
||||
case "unlock" -> {
|
||||
Pipeline.check(!pending, "Pending");
|
||||
locked = false;
|
||||
}
|
||||
case "state" -> {
|
||||
return state.current() == null ? "{}" : state.json();
|
||||
}
|
||||
case "pending" -> pending = true;
|
||||
case "finish" -> pending = false;
|
||||
case "commit" -> state = ReleaseState.parse(input);
|
||||
case "prepare" -> {
|
||||
return "S6_BACKEND=" + deployment("v1.0.0", SHA, "attempt").backend() + "\nS6_FRONTEND="
|
||||
+ deployment("v1.0.0", SHA, "attempt").frontend();
|
||||
}
|
||||
case "apply" -> Pipeline.check(!failedDirectories.contains(args[0]), "Injected unhealthy release");
|
||||
default -> {}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static class Fixture implements AutoCloseable {
|
||||
final Path root = Files.createTempDirectory("release-test-");
|
||||
final Map<String, String> env = environment();
|
||||
final List<String> events = new ArrayList<>();
|
||||
final FakeApi api;
|
||||
final FakeRemote remote;
|
||||
final Release release;
|
||||
|
||||
Fixture() throws Exception {
|
||||
this("new");
|
||||
}
|
||||
|
||||
Fixture(String mode) throws Exception {
|
||||
Files.createDirectories(root.resolve("deploy"));
|
||||
Files.writeString(root.resolve("deploy/remote.sh"), "");
|
||||
Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n");
|
||||
env.put("RELEASE_MODE", mode);
|
||||
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
|
||||
api = new FakeApi(env, events);
|
||||
if (mode.equals("retry")) api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
|
||||
remote = new FakeRemote(root, env, events);
|
||||
release = new Release(root, env, api, remote, mode.equals("rollback") ? "rollback" : "deploy", "attempt");
|
||||
}
|
||||
|
||||
void withPrior() {
|
||||
remote.state = prior();
|
||||
api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.1"), OLD, OLD));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
remote.close();
|
||||
Pipeline.deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
static void apiTests() throws Exception {
|
||||
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
|
||||
CiTests.equal("127.0.0.1:3000/owner/agiledancers-", f.release.prefix());
|
||||
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
|
||||
CiTests.failure(f.release::prefix);
|
||||
}
|
||||
});
|
||||
test("repeating remote tag pages fail closed instead of allocating from incomplete history", () -> {
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return List.of(Map.of("name", "v1.0.0", "id", OLD, "commit", Map.of("sha", SHA)));
|
||||
}
|
||||
};
|
||||
CiTests.failure(api::tags);
|
||||
});
|
||||
test("annotated release tag must identify the recorded version and exact SHA", () -> {
|
||||
var tag = new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD);
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("tag", "v1.0.0", "object", Map.of("sha", SHA));
|
||||
}
|
||||
};
|
||||
api.annotated(tag);
|
||||
var wrong = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("tag", "v1.0.0", "object", Map.of("sha", OLD));
|
||||
}
|
||||
};
|
||||
CiTests.failure(() -> wrong.annotated(tag));
|
||||
});
|
||||
test("Gitea API eligibility validates SHA, event, branch, workflow path and successful conclusion", () -> {
|
||||
var valid = Map.<String, Object>of(
|
||||
"id",
|
||||
1,
|
||||
"head_sha",
|
||||
SHA,
|
||||
"event",
|
||||
"push",
|
||||
"head_branch",
|
||||
"main",
|
||||
"path",
|
||||
"build.yaml@refs/heads/main",
|
||||
"status",
|
||||
"completed",
|
||||
"conclusion",
|
||||
"success");
|
||||
for (String field : List.of("head_sha", "event", "head_branch", "path", "status", "conclusion")) {
|
||||
var invalid = new HashMap<>(valid);
|
||||
invalid.put(field, "wrong");
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("workflow_runs", List.of(invalid));
|
||||
}
|
||||
};
|
||||
CiTests.failure(() -> api.verified(SHA));
|
||||
}
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("workflow_runs", List.of(valid));
|
||||
}
|
||||
};
|
||||
CiTests.equal("1", api.verified(SHA));
|
||||
});
|
||||
test("remote tag pagination includes failed-release baseline and rejects ambiguous tag formats", () -> {
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return path.endsWith("page=1")
|
||||
? List.of(Map.of("name", "v1.2.3", "id", OLD, "commit", Map.of("sha", SHA)))
|
||||
: List.of();
|
||||
}
|
||||
};
|
||||
CiTests.equal(
|
||||
"v1.2.4",
|
||||
ReleaseVersion.next(
|
||||
api.tags().stream()
|
||||
.map(ReleaseApi.Tag::version)
|
||||
.toList(),
|
||||
"patch")
|
||||
.toString());
|
||||
var invalid = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return List.of(Map.of("name", "1.0", "id", OLD, "commit", Map.of("sha", SHA)));
|
||||
}
|
||||
};
|
||||
CiTests.failure(invalid::tags);
|
||||
});
|
||||
}
|
||||
|
||||
static void shellTests() throws Exception {
|
||||
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
|
||||
Path directory = Files.createTempDirectory("release-shell-test-");
|
||||
try {
|
||||
String script = Files.readString(Path.of("deploy/remote.sh"));
|
||||
var commands = new Commands(Duration.ofSeconds(30));
|
||||
var env = environment();
|
||||
var first = commands.execute(
|
||||
List.of("sh", "-c", script, "--", "lock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false);
|
||||
first.checked();
|
||||
CiTests.equal(
|
||||
1,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "lock", directory.toString(), "two"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.code());
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "pending", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
"{\"candidate\":\"test\"}",
|
||||
false)
|
||||
.checked();
|
||||
CiTests.equal(
|
||||
1,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.code());
|
||||
CiTests.equal(true, Files.exists(directory.resolve("pending.json")));
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "finish", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked();
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked();
|
||||
CiTests.equal(false, Files.exists(directory.resolve("deploy.lock")));
|
||||
CiTests.equal(true, Files.exists(directory.resolve("history/one.json")));
|
||||
} finally {
|
||||
Pipeline.deleteTree(directory);
|
||||
}
|
||||
});
|
||||
test("SSH shell quoting preserves metacharacters as data", () -> {
|
||||
String value = "quote' dollar$ backtick` newline\n";
|
||||
var commands = new Commands(Duration.ofSeconds(5));
|
||||
CiTests.equal(
|
||||
value,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", "printf '%s' " + ReleaseRemote.quote(value)),
|
||||
Path.of("."),
|
||||
environment(),
|
||||
null,
|
||||
false)
|
||||
.output());
|
||||
});
|
||||
}
|
||||
}
|
||||
39
ci/ReleaseVersion.java
Normal file
39
ci/ReleaseVersion.java
Normal file
@@ -0,0 +1,39 @@
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Strict stable release versions; arbitrary-size integers avoid overflow/reuse. */
|
||||
record ReleaseVersion(BigInteger major, BigInteger minor, BigInteger patch) implements Comparable<ReleaseVersion> {
|
||||
private static final Pattern FORMAT = Pattern.compile("v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
|
||||
|
||||
static ReleaseVersion parse(String value) {
|
||||
var match = FORMAT.matcher(value);
|
||||
Pipeline.check(match.matches(), "Expected release version vMAJOR.MINOR.PATCH");
|
||||
return new ReleaseVersion(
|
||||
new BigInteger(match.group(1)), new BigInteger(match.group(2)), new BigInteger(match.group(3)));
|
||||
}
|
||||
|
||||
static ReleaseVersion next(List<ReleaseVersion> versions, String increment) {
|
||||
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
|
||||
var highest = versions.stream().max(ReleaseVersion::compareTo);
|
||||
if (highest.isEmpty()) return parse("v1.0.0");
|
||||
var current = highest.get();
|
||||
return switch (increment) {
|
||||
case "major" -> new ReleaseVersion(current.major.add(BigInteger.ONE), BigInteger.ZERO, BigInteger.ZERO);
|
||||
case "minor" -> new ReleaseVersion(current.major, current.minor.add(BigInteger.ONE), BigInteger.ZERO);
|
||||
default -> new ReleaseVersion(current.major, current.minor, current.patch.add(BigInteger.ONE));
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ReleaseVersion other) {
|
||||
int value = major.compareTo(other.major);
|
||||
if (value == 0) value = minor.compareTo(other.minor);
|
||||
return value == 0 ? patch.compareTo(other.patch) : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "v" + major + "." + minor + "." + patch;
|
||||
}
|
||||
}
|
||||
34
deploy/compose.yaml
Normal file
34
deploy/compose.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Images are resolved to registry digests before replacement. Never use :main here.
|
||||
services:
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:?Required verified backend digest}
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
labels:
|
||||
nl.agilecc.release: ${RELEASE_VERSION:?Required release version}
|
||||
nl.agilecc.revision: ${RELEASE_SHA:?Required full commit SHA}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:?Required verified frontend digest}
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_UPSTREAM: backend:8080
|
||||
ports:
|
||||
- '${BIND_IP:?Required private NAS IP}:${FRONTEND_PORT:?Required port}:8080'
|
||||
labels:
|
||||
nl.agilecc.release: ${RELEASE_VERSION:?Required release version}
|
||||
nl.agilecc.revision: ${RELEASE_SHA:?Required full commit SHA}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
28
deploy/enable-docker.sh
Normal file
28
deploy/enable-docker.sh
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# One-time DSM administrator action, run with sudo; does not restart any service.
|
||||
set -eu
|
||||
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
|
||||
account=agiledancers-deploy
|
||||
docker=/usr/local/bin/docker
|
||||
target=/etc/sudoers.d/agiledancers-deploy
|
||||
rule="$account ALL=(root) NOPASSWD: $docker"
|
||||
test -x "$docker" && id "$account" >/dev/null
|
||||
test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; }
|
||||
if [ -e "$target" ]; then
|
||||
test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; }
|
||||
else
|
||||
umask 077
|
||||
temporary=$(mktemp /etc/sudoers.d/agiledancers-deploy.XXXXXX)
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
printf '%s\n' "$rule" > "$temporary"
|
||||
chmod 440 "$temporary"
|
||||
if command -v visudo >/dev/null 2>&1; then visudo -cf "$temporary"; fi
|
||||
# A dot in the temporary name keeps sudo from loading the uncommitted file.
|
||||
mv "$temporary" "$target"
|
||||
if ! sudo -n -u "$account" sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'; then
|
||||
rm "$target"
|
||||
echo 'Docker permission check failed; new sudoers entry removed' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo 'Dedicated Docker sudo permission is configured; no services were restarted.'
|
||||
9
deploy/production.env.example
Normal file
9
deploy/production.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy to <DEPLOY_DIRECTORY>/production.env on the NAS, mode 600.
|
||||
# This is shell syntax: do not add spaces around '=' or put secrets here.
|
||||
BIND_IP=192.168.2.28
|
||||
FRONTEND_PORT=8088
|
||||
DOCKER_BIN=/usr/local/bin/docker
|
||||
# 1 uses sudo -n for Docker only; configure the documented sudoers entry first.
|
||||
DOCKER_SUDO=1
|
||||
# The pull-only registry login is provisioned separately using docker login.
|
||||
DOCKER_CONFIG=/volume1/docker/agiledancers-production/docker-auth
|
||||
38
deploy/provision.sh
Normal file
38
deploy/provision.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
# Account-level provisioning. Docker sudo must already be enabled separately.
|
||||
set -eu
|
||||
umask 077
|
||||
root=$1
|
||||
registry_user=$2
|
||||
registry=$3
|
||||
bind=$4
|
||||
port=$5
|
||||
docker=/usr/local/bin/docker
|
||||
test -x "$docker"
|
||||
sudo -n "$docker" compose version
|
||||
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'
|
||||
test ! -L "$root"
|
||||
mkdir -p "$root" "$root/docker-auth"
|
||||
chmod 700 "$root" "$root/docker-auth"
|
||||
if [ ! -f "$root/production.env" ]; then
|
||||
# First provisioning must not take a port already occupied by another service.
|
||||
command -v netstat >/dev/null
|
||||
if netstat -lnt | awk -v port=":$port" '$4 ~ (port "$") { found=1 } END { exit !found }'; then
|
||||
echo 'Frontend port is already occupied; production configuration unchanged' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'BIND_IP=%s\nFRONTEND_PORT=%s\nDOCKER_BIN=%s\nDOCKER_SUDO=1\nDOCKER_CONFIG=%s/docker-auth\n' \
|
||||
"$bind" "$port" "$docker" "$root" > "$root/production.env"
|
||||
chmod 600 "$root/production.env"
|
||||
else
|
||||
# Preserve existing config; refuse to silently rebind a configured deployment.
|
||||
. "$root/production.env"
|
||||
test "$BIND_IP" = "$bind" && test "$FRONTEND_PORT" = "$port" \
|
||||
&& test "$DOCKER_CONFIG" = "$root/docker-auth" || {
|
||||
echo 'Existing production.env differs; review it before provisioning' >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
# Credential enters only via stdin. No password, environment or auth config is logged.
|
||||
sudo -n "$docker" --config "$root/docker-auth" login "$registry" --username "$registry_user" --password-stdin
|
||||
echo 'Production directory and dedicated registry login prepared; no containers were changed.'
|
||||
186
deploy/remote.sh
Normal file
186
deploy/remote.sh
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/bin/sh
|
||||
# Invoked over a pinned SSH connection by ReleaseRemote. No secrets in arguments.
|
||||
# mkdir is the NAS-wide lock; a disconnected runner leaves it for explicit recovery.
|
||||
set -eu
|
||||
umask 077
|
||||
action=$1
|
||||
root=$2
|
||||
owner=$3
|
||||
shift 3
|
||||
die() { echo "S6: $*" >&2; exit 1; }
|
||||
case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac
|
||||
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
|
||||
test -d "$root" || die 'Provision the deployment directory first'
|
||||
cd "$root"
|
||||
lock="$root/deploy.lock"
|
||||
release="$root/releases/$owner"
|
||||
|
||||
if [ "$action" = lock ]; then
|
||||
test ! -e pending.json || die 'Unresolved deployment: follow manual recovery runbook'
|
||||
mkdir "$lock" 2>/dev/null || die 'Deployment locked: another run or manual recovery owns the NAS'
|
||||
printf '%s\n' "$owner" > "$lock/owner"
|
||||
# Check again after acquiring, covering races with another lock holder.
|
||||
if [ -e pending.json ]; then
|
||||
rm "$lock/owner"
|
||||
rmdir "$lock"
|
||||
die 'Unresolved deployment: follow manual recovery runbook'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
test -f "$lock/owner" && [ "$(cat "$lock/owner")" = "$owner" ] || die 'Deployment lock ownership mismatch'
|
||||
|
||||
case "$action" in
|
||||
unlock)
|
||||
test ! -e pending.json || die 'Pending deployment retained; manual recovery required'
|
||||
rm "$lock/owner"
|
||||
rmdir "$lock"
|
||||
exit 0;;
|
||||
state)
|
||||
if [ -f state.json ]; then cat state.json; else printf '{}\n'; fi
|
||||
exit 0;;
|
||||
pending)
|
||||
test ! -e pending.json || die 'Deployment already pending'
|
||||
cat > pending.json.tmp
|
||||
mv pending.json.tmp pending.json
|
||||
exit 0;;
|
||||
commit)
|
||||
test -f pending.json || die 'No pending deployment'
|
||||
cat > state.json.tmp
|
||||
mv state.json.tmp state.json
|
||||
exit 0;;
|
||||
finish)
|
||||
test -f pending.json || die 'No pending deployment'
|
||||
mkdir -p history
|
||||
mv pending.json "history/$owner.json"
|
||||
exit 0;;
|
||||
event)
|
||||
mkdir -p history
|
||||
cat > "history/$owner-outcome.json.tmp"
|
||||
mv "history/$owner-outcome.json.tmp" "history/$owner-outcome.json"
|
||||
exit 0;;
|
||||
esac
|
||||
|
||||
test -f production.env || die 'Missing production.env; follow provisioning runbook'
|
||||
# This account-owned file is trusted shell configuration, never a workflow input.
|
||||
. "$root/production.env"
|
||||
: "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}"
|
||||
export DOCKER_CONFIG
|
||||
docker_cmd() {
|
||||
if [ "${DOCKER_SUDO:-1}" = 1 ]; then
|
||||
sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||
else
|
||||
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||
fi
|
||||
}
|
||||
compose() {
|
||||
docker_cmd compose --project-name agiledancers-production --project-directory "$root" \
|
||||
--env-file "$release/release.env" -f "$release/compose.yaml" "$@"
|
||||
}
|
||||
identity() {
|
||||
image=$1
|
||||
sha=$2
|
||||
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|
||||
|| die 'Image revision does not match selected SHA'
|
||||
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \
|
||||
|| die 'Image platform must be linux/amd64'
|
||||
}
|
||||
use_release() {
|
||||
case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac
|
||||
release="$root/releases/$1"
|
||||
test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration'
|
||||
}
|
||||
health() {
|
||||
backend=$(compose ps -q backend)
|
||||
frontend=$(compose ps -q frontend)
|
||||
test -n "$backend" && test -n "$frontend" || return 1
|
||||
# Compare image IDs, revision labels and the container release labels.
|
||||
for role in backend frontend; do
|
||||
id=$(compose ps -q "$role")
|
||||
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
|
||||
test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1
|
||||
test "$(docker_cmd inspect --format '{{.Image}}' "$id")" = "$(docker_cmd image inspect --format '{{.Id}}' "$expected")" || return 1
|
||||
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1
|
||||
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
|
||||
done
|
||||
docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1
|
||||
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1
|
||||
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
preflight)
|
||||
command -v timeout >/dev/null || die 'timeout is required for bounded remote operations'
|
||||
command -v curl >/dev/null || die 'curl is required'
|
||||
test -x "$DOCKER_BIN" || die 'Docker executable missing'
|
||||
docker_cmd compose version >/dev/null
|
||||
test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \
|
||||
|| test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \
|
||||
|| die 'Synology Docker platform is not linux/amd64'
|
||||
test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first'
|
||||
# Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication.
|
||||
case "$BIND_IP" in
|
||||
10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.30.*|172.31.*|127.*) ;;
|
||||
*) die 'Bind frontend to a private IPv4 address';;
|
||||
esac
|
||||
case "$FRONTEND_PORT" in ''|*[!0-9]*) die 'Invalid frontend port';; esac
|
||||
test "$FRONTEND_PORT" -ge 1024 && test "$FRONTEND_PORT" -le 65535 || die 'Use port 1024..65535'
|
||||
;;
|
||||
stage)
|
||||
mkdir -p "$release"
|
||||
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
|
||||
cat > "$release/compose.yaml"
|
||||
;;
|
||||
prepare)
|
||||
version=$1; sha=$2; prefix=$3
|
||||
backend=''; frontend=''
|
||||
for role in backend frontend; do
|
||||
repository="$prefix$role"
|
||||
image="$repository:$sha"
|
||||
docker_cmd pull --platform linux/amd64 "$image" >&2
|
||||
identity "$image" "$sha"
|
||||
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
|
||||
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
|
||||
test -n "$digest" || die 'Missing registry digest'
|
||||
if [ "$role" = backend ]; then backend=$digest; else frontend=$digest; fi
|
||||
done
|
||||
printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \
|
||||
"$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env"
|
||||
compose config --quiet
|
||||
printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend"
|
||||
;;
|
||||
ensure)
|
||||
use_release "$1"
|
||||
. "$release/release.env"
|
||||
test "$RELEASE_VERSION" = "$2" && test "$RELEASE_SHA" = "$3" \
|
||||
&& test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \
|
||||
|| die 'Retained configuration differs from recorded successful release'
|
||||
for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do
|
||||
docker_cmd pull --platform linux/amd64 "$image" >&2
|
||||
identity "$image" "$RELEASE_SHA"
|
||||
done
|
||||
compose config --quiet
|
||||
;;
|
||||
apply)
|
||||
mkdir "$lock/operation" 2>/dev/null || die 'A remote replacement is still active; manual recovery required'
|
||||
trap 'rmdir "$lock/operation"' EXIT
|
||||
use_release "$1"
|
||||
. "$release/release.env"
|
||||
health_seconds=${2:-280}
|
||||
case "$health_seconds" in ''|*[!0-9]*) die 'Invalid readiness window';; esac
|
||||
test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window'
|
||||
end=$(( $(date +%s) + health_seconds ))
|
||||
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
||||
compose up -d --no-build --pull never --force-recreate --timeout 20
|
||||
while ! health; do
|
||||
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
||||
sleep 1
|
||||
done
|
||||
echo 'S6_HEALTHY'
|
||||
;;
|
||||
diagnose)
|
||||
use_release "$1"
|
||||
compose ps
|
||||
compose logs --no-color --tail 200
|
||||
;;
|
||||
*) die 'Unknown remote deployment operation';;
|
||||
esac
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
De workflow staat in `.gitea/workflows/build.yaml`. Elke branchpush bouwt en test
|
||||
backend en frontend, bouwt twee images en draait Playwright tegen die containers.
|
||||
Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats.
|
||||
Alleen `main` mag daarna images publiceren. Deze buildworkflow voert geen deployment uit.
|
||||
S6 voegt aparte handmatige workflows toe voor productie en rollback; zie
|
||||
[productiedeployment](production-deployment.md).
|
||||
Tagpushes en aparte pull-requestevents starten geen extra workflow. Een auteur
|
||||
kan een branchbuild bewust overslaan met een door Gitea ondersteunde markering,
|
||||
zoals `[skip ci]`; dit is expliciet toegestaan.
|
||||
|
||||
281
docs/production-deployment.md
Normal file
281
docs/production-deployment.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Productie uitrollen op de Synology (S6)
|
||||
|
||||
De buildworkflow bouwt, test en publiceert automatisch. Productie verandert
|
||||
alleen via **Actions → Deploy production → Run workflow** of **Rollback
|
||||
production**. Start beide vanaf `main`; de workflows verschijnen na de merge.
|
||||
|
||||
**Tot S16 is geïmplementeerd kan iedere deployment, retry, rollback of automatisch
|
||||
herstel alle applicatiegegevens wissen.** Informeer de launching partner vooraf.
|
||||
Een onderbroken berekening moet opnieuw worden gestart. Een herstelde
|
||||
applicatieversie herstelt geen verloren gegevens.
|
||||
|
||||
## Eenmalige DSM-inrichting
|
||||
|
||||
Gebruik je bestaande beheeraccount voor deze stappen:
|
||||
|
||||
1. Maak onder **Configuratiescherm → Gebruiker en groep → Gebruiker → Maken**
|
||||
het account `agiledancers-deploy` met een uniek sterk wachtwoord. Voeg het toe
|
||||
aan **administrators**: DSM vereist deze groep voor SSH. Het is daardoor een
|
||||
beheerder, geen beperkt bestandsaccount.
|
||||
2. Bij **Gedeelde mapmachtigingen**: `docker` op **Lezen/Schrijven**, `homes`
|
||||
ongewijzigd, overige mappen op **Geen toegang**. `git` is niet nodig: deployment
|
||||
gebruikt de Gitea-API en registry. Blokkeer `homes` niet, omdat dit ook de eigen
|
||||
thuismap kan blokkeren.
|
||||
3. De toepassingen onder **Toegangsmachtigingen toewijzen** zijn niet nodig en
|
||||
mogen op **Weigeren**. Gebruik je eigen beheeraccount voor DSM. Deployment
|
||||
gebruikt SSH-shellopdrachten, geen SFTP, rsync of bestandsdeling.
|
||||
4. Schakel onder **Gebruiker en groep → Geavanceerd** de
|
||||
**gebruikersbasismapservice** in als deze uitstaat.
|
||||
5. Schakel **Terminal en SNMP → Terminal → SSH-service inschakelen** in. De
|
||||
maintainer heeft SSH-poort **22** bevestigd. Beperk toegang tot mini-pc/LAN/VPN;
|
||||
configureer geen internet-portforwarding.
|
||||
6. Controleer dat Container Manager/Docker draait, dat Docker Compose **v2**,
|
||||
`curl`, `timeout` met `-k` en `sh` beschikbaar zijn. Op de NAS is geen Java,
|
||||
Python of Git nodig. Controleer het Docker-pad, doorgaans `/usr/local/bin/docker`.
|
||||
|
||||
Map- en toepassingsrechten vormen geen beveiligde isolatie voor een account met
|
||||
beheerder-/Dockerrechten. De runner voert ook vertrouwde repositorycode uit.
|
||||
Geef alleen vertrouwde beheerders schrijftoegang tot workflows en credentials.
|
||||
|
||||
Bronnen: [SSH op DSM](https://kb.synology.com/en-au/DSM/help/DSM/AdminCenter/system_terminal),
|
||||
[sleutelaanmelding en basismappen](https://kb.synology.com/en-us/DSM/tutorial/How_to_log_in_to_DSM_with_key_pairs_as_admin_or_root_permission_via_SSH_on_computers),
|
||||
[maprechten](https://kb.synology.com/nl-nl/DSM/help/DSM/AdminCenter/file_share_privilege?version=7).
|
||||
|
||||
## SSH-sleutel en hostkey
|
||||
|
||||
Maak op de mini-pc een aparte sleutel buiten de repository. De lege passphrase
|
||||
maakt niet-interactieve uitvoering mogelijk; behandel de private sleutel als secret.
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f "$HOME/.config/agiledancers-runner/s6-deploy-key" -N '' -C agiledancers-production
|
||||
ssh-copy-id -i "$HOME/.config/agiledancers-runner/s6-deploy-key.pub" -p 22 agiledancers-deploy@192.168.2.28
|
||||
```
|
||||
|
||||
Genereer niet opnieuw als de sleutel al bestaat. De implementatiesessie heeft
|
||||
deze dedicated sleutel op de mini-pc aangemaakt. De gebruiker installeert de
|
||||
publieke sleutel via een eigen interactieve terminal; deel geen wachtwoorden.
|
||||
|
||||
Controleer de serverfingerprint via een reeds vertrouwde beheerverbinding, tegen
|
||||
`/etc/ssh/ssh_host_ed25519_key.pub`. Een keyscan alleen bewijst de serveridentiteit
|
||||
niet. Bewaar de geverifieerde known_hosts-regel; bij een andere poort gebruikt
|
||||
die `[host]:poort`. De helper vereist `StrictHostKeyChecking=yes`, gebruikt alleen
|
||||
de dedicated sleutel en negeert SSH-agent en lokale SSH-configuratie.
|
||||
|
||||
Op de NAS: eigen `~/.ssh` mode `700`, `authorized_keys` mode `600`, juiste eigenaar,
|
||||
thuismap niet schrijfbaar voor andere gebruikers. De public-keyregel kan worden
|
||||
beperkt met de werkelijke vaste mini-pc-IP en zonder forwarding/TTY:
|
||||
|
||||
```text
|
||||
from="<mini-pc-LAN-IP>",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-ed25519 <public-key> agiledancers-production
|
||||
```
|
||||
|
||||
Als de sleutel na `ssh-copy-id` nog wordt geweigerd door ruime DSM-basismaprechten,
|
||||
voer dan eenmaal met wachtwoordauthenticatie uit als dit account:
|
||||
|
||||
```sh
|
||||
chmod go-w "$HOME"
|
||||
chmod 700 "$HOME/.ssh"
|
||||
chmod 600 "$HOME/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
## Productiemap en Dockerrechten
|
||||
|
||||
Controleer het volume: `/volume1/docker/agiledancers-production` is een voorbeeld,
|
||||
geen vastgestelde locatie. Maak de map als deploymentaccount, mode `700`, met
|
||||
dat account als eigenaar. Houd deze stack gescheiden van Gitea en andere projecten.
|
||||
|
||||
Kopieer `deploy/production.env.example` naar `<productiemap>/production.env`, mode
|
||||
`600`. Stel de private NAS-IP, vrije frontendpoort (voorbeeld `8088`), absolute
|
||||
Docker-locatie en `DOCKER_CONFIG` in. Gebruik een eigen `docker-auth`-submap.
|
||||
Alleen de frontend bindt aan de private IPv4; de backend krijgt geen hostpoort.
|
||||
De Docker-projectnaam is vast `agiledancers-production`.
|
||||
|
||||
Als Docker sudo vereist, laat een beheerder met `visudo` een afzonderlijke
|
||||
sudoers-regel installeren en valideren, met het gecontroleerde Docker-pad:
|
||||
|
||||
```text
|
||||
agiledancers-deploy ALL=(root) NOPASSWD: /usr/local/bin/docker
|
||||
```
|
||||
|
||||
Dit staat alleen dat executable toe, maar Dockerbeheer geeft in de praktijk
|
||||
rootmogelijkheden. Gebruik geen `NOPASSWD: ALL`, wereldschrijfbare Docker-socket
|
||||
of wachtwoord in de workflow. De helper gebruikt `sudo -n`. Als Docker al direct
|
||||
toegankelijk is voor het account, stel `DOCKER_SUDO=0` in.
|
||||
|
||||
Provision apart een **pull-only** Gitea-packagecredential. Log als deploymentaccount
|
||||
op de NAS in, via de interactieve wachtwoordprompt:
|
||||
|
||||
```sh
|
||||
mkdir -p /volume1/docker/agiledancers-production/docker-auth
|
||||
chmod 700 /volume1/docker/agiledancers-production/docker-auth
|
||||
sudo -n /usr/local/bin/docker --config /volume1/docker/agiledancers-production/docker-auth login 127.0.0.1:3000 --username <pull-account>
|
||||
sudo -n /usr/local/bin/docker compose version
|
||||
sudo -n /usr/local/bin/docker info --format '{{.OSType}}/{{.Architecture}}'
|
||||
```
|
||||
|
||||
Als DSM geen `visudo` bevat, is `deploy/enable-docker.sh` het eenmalige alternatief.
|
||||
Kopieer het naar de eigen NAS-thuismap en voer het daar met sudo uit. Het maakt
|
||||
uitsluitend de bovengenoemde regel, controleert die door als deploymentaccount
|
||||
Docker te starten en verwijdert een nieuwe regel als die controle faalt. Een
|
||||
afwijkende bestaande regel wordt niet overschreven. De implementatiesessie heeft
|
||||
deze stap op de NAS uitgevoerd; Docker rapporteert `linux/x86_64`.
|
||||
|
||||
Pas paden aan voor de echte configuratie. De bevestigde NAS-Dockerdaemon staat
|
||||
HTTP-registryverkeer alleen op loopback toe. Gebruik daarom vanaf die NAS
|
||||
`127.0.0.1:3000`: dezelfde Gitea-registry en gepubliceerde SHA-images, zonder een
|
||||
daemonherstart die Gitea en Kimai zou onderbreken. De mini-pc blijft naar
|
||||
`192.168.2.28:3000` publiceren. Deploymentrecords bevatten de op de NAS bruikbare
|
||||
loopback-digestreferenties. Authenticatie-/netwerkfouten zijn deploymentfouten.
|
||||
|
||||
## Gitea-configuratie
|
||||
|
||||
Stel onder repository **Settings → Actions** in:
|
||||
|
||||
| Soort | Naam | Inhoud |
|
||||
| --- | --- | --- |
|
||||
| Variabele | `DEPLOY_HOST` | Private NAS-IP/hostnaam, passend bij known_hosts |
|
||||
| Variabele | `DEPLOY_USER` | `agiledancers-deploy` |
|
||||
| Variabele | `DEPLOY_SSH_PORT` | `22` |
|
||||
| Variabele | `DEPLOY_DIRECTORY` | Absolute productiemap |
|
||||
| Variabele | `DEPLOY_REGISTRY` | `127.0.0.1:3000`, de NAS-lokale alias van dezelfde registry |
|
||||
| Secret | `DEPLOY_SSH_KEY` | Volledige private sleutel, inclusief begin/eindregels |
|
||||
| Secret | `DEPLOY_KNOWN_HOSTS` | Onafhankelijk geverifieerde NAS-hostkey-regel |
|
||||
| Secret | `RELEASE_TOKEN` | Apart Gitea-token voor Actions lezen en Git-tags maken |
|
||||
|
||||
Het releaseaccount heeft repositorylees-/schrijfrechten en Actions-metadatatoegang
|
||||
nodig (`write:repository` op deze Gitea). Het NAS-registrycredential is apart en
|
||||
heeft alleen de noodzakelijke package-leestoegang. Hergebruik de publicatie-PAT
|
||||
niet voor deployment. Bewaar secrets buiten Git en logs.
|
||||
|
||||
De setuphelper kan deze configuratie en de productiemap zonder containerstart
|
||||
inrichten. Bewaar `admin-token`, `s6-release-token`, `s6-pull-token` en de private
|
||||
`s6-deploy-key` als owner-only bestanden (mode `600`) in
|
||||
`~/.config/agiledancers-runner/`. Gebruik de eerder vertrouwde NAS-hostkey in
|
||||
`~/.ssh/known_hosts` en voer uit:
|
||||
|
||||
```sh
|
||||
java ci/Ci.java configure-deployment
|
||||
```
|
||||
|
||||
Defaults zijn de bevestigde NAS/account/SSH-poort, repository
|
||||
`wanjakrah/agiledancers`, productiemap `/volume1/docker/agiledancers-production`,
|
||||
NAS-IP-binding `192.168.2.28` en frontendpoort `8088`. De helper controleert een
|
||||
geslaagde main-build, weigert een bezette poort bij eerste inrichting, bewaart
|
||||
bestaande productieconfiguratie en controleert de aparte registry-login voordat
|
||||
Gitea-secrets/variabelen worden gezet. Er wordt geen release/tag gemaakt en geen
|
||||
container vervangen. Variabelen met de namen uit bovenstaande tabel en
|
||||
`DEPLOY_BIND_IP`, `DEPLOY_FRONTEND_PORT`, `PULL_USER` en de credential-`*_FILE`-
|
||||
paden kunnen de defaults expliciet vervangen.
|
||||
|
||||
Bescherm release-tags `v*` in Gitea en beperk tagcreatie tot de releasebeheerder.
|
||||
De helper maakt alleen nieuwe tags; hij verplaatst of verwijdert ze nooit. Laat
|
||||
registry-cleanup de images van huidig/vorig niet verwijderen. Gebruik geen
|
||||
algemene Docker-image/volume-prune als onderhoud van deze stack.
|
||||
|
||||
## Nieuwe release, retry en rollback
|
||||
|
||||
1. Merge naar `main` en wacht op een geslaagde **Build, test and publish** voor
|
||||
die SHA, inclusief imagepublicatie.
|
||||
2. Open **Deploy production → Run workflow**, branch `main`, mode `new`, keuze
|
||||
`patch`, `minor` of `major`. Laat het versieveld leeg. De eerste release is
|
||||
`v1.0.0`; daarna telt de hoogste bestaande release-tag, inclusief mislukte
|
||||
deployments. Minor/major zetten de lagere componenten op nul.
|
||||
3. De workflow toont versie/SHA. De immutable annotated Git-tag wordt na
|
||||
imagevoorbereiding, vóór vervanging gemaakt en blijft bij falen bestaan.
|
||||
Een tag bewijst geen succesvolle productiedeployment.
|
||||
4. Gebruik mode `retry` met een bestaande tag om dezelfde SHA/versie opnieuw uit
|
||||
te rollen. De incrementkeuze wordt genegeerd: Gitea toont alle velden, ook
|
||||
wanneer ze voor de gekozen mode niet relevant zijn. Een retry mag afwijken
|
||||
van huidig main, maar geen nieuwere succesvolle release verdringen.
|
||||
5. **Rollback production → Run workflow → main** herstelt de vorige succesvolle
|
||||
release zonder versie/tag. Na succes wisselen huidig/vorig om; nogmaals
|
||||
rollback kan dus terugschakelen. De hoogste succesvolle versie blijft bewaard.
|
||||
|
||||
De NAS-lock omvat alle modes en versieallocatie. Een gelijktijdige run faalt
|
||||
zichtbaar. Gitea 1.25 negeert workflow-`concurrency`; de serverlock is leidend.
|
||||
Main wordt direct vóór vervanging opnieuw gecontroleerd. Zodra vervanging start,
|
||||
wordt de gepinde versie afgemaakt of hersteld. Branchbuilds rollen niets uit.
|
||||
|
||||
## Checks en operationele state
|
||||
|
||||
De helper haalt beide SHA-images terug, controleert revisie/platform en legt
|
||||
registry-digests vast. Compose gebruikt die digests, nooit de bewegende main-tag.
|
||||
De recovery-images worden eveneens vooraf gecontroleerd. Binnen vijf minuten
|
||||
moeten backend-readiness, frontendpagina, GET `/api/dance-schools` via de
|
||||
gepubliceerde frontendpoort en containeridentiteit slagen. Deze checks schrijven
|
||||
geen gebruikersdata. De frontend herstart ook om de nieuwe backend-IP te resolven.
|
||||
|
||||
De productiemap bevat:
|
||||
|
||||
- `state.json`: atomisch geschreven huidig/vorig en hoogste succesvolle versie.
|
||||
Redeployment van dezelfde versie behoudt de afzonderlijke rollbackversie.
|
||||
- `releases/<attempt>/`: behouden Compose- en digest-/versieconfiguratie.
|
||||
- `pending.json`: kandidaat en vorige state, vóór iedere vervanging geschreven.
|
||||
- `deploy.lock/owner`: unieke run/poging; `operation/` markeert een actieve
|
||||
vervanging op de NAS.
|
||||
- `history/`: pogingjournalen en uitkomsten; niet opruimen tijdens een release.
|
||||
|
||||
Een ongezonde kandidaat herstelt automatisch de vorige succesvolle release en
|
||||
blijft een mislukte workflow. Bij de eerste release zonder voorganger, mislukt
|
||||
herstel, runner/SSH-onderbreking of onduidelijke statecommit blijven journal en
|
||||
lock staan. Nieuwe runs weigeren dan wijzigingen. Remote opdrachten hebben ook
|
||||
op de NAS een timeout; herstel overlapt geen nog actieve vervanging. Geen
|
||||
eindeloze herstelpogingen.
|
||||
|
||||
Download artifact `deployment-<run>-<attempt>` of `rollback-<run>-<attempt>` voor
|
||||
log, uitkomst, versie/SHA/digests en beschikbare foutdiagnostiek. Retentie is
|
||||
veertien dagen; serverinstellingen mogen dit niet inkorten. Log geen environment,
|
||||
Docker-authconfig of volledige container-inspect met eventuele secrets.
|
||||
|
||||
## Handmatig herstel
|
||||
|
||||
Gebruik een vertrouwde beheerverbinding. Verwijder nooit alleen de lock om een
|
||||
nieuwe release door te drukken.
|
||||
|
||||
1. Annuleer de betrokken Gitea-run. Controleer op de NAS dat geen SSH-deployment,
|
||||
timeout, Compose-vervanging of herstel van deze poging meer actief is. Onderzoek
|
||||
`deploy.lock/operation`; wacht op de timeout of beëindig alleen de geverifieerde
|
||||
vastgelopen deploymentprocessen.
|
||||
2. Bewaar state, pending journal, history en containerlogs. Vergelijk de kandidaat,
|
||||
de `before`-state en werkelijke images. De statecommit kan al zijn uitgevoerd
|
||||
terwijl de runner geen antwoord ontving.
|
||||
3. Behoud een aantoonbaar gezonde kandidaat met overeenkomende state. Anders:
|
||||
herstel `before.current` uit het journal met de behouden Compose-/env-bestanden,
|
||||
dezelfde projectnaam en digest-images. Start geen tweede stack.
|
||||
4. Voer dezelfde checks uit. Vanaf de checkout op de mini-pc kun je het echte
|
||||
controlescript uitvoeren met bestaande lockowner en gekozen release-directory:
|
||||
|
||||
```sh
|
||||
ssh -i <private-key-path> -p <ssh-port> <deploy-user>@<nas> \
|
||||
'timeout -k 5 300 sh -s -- apply <production-directory> <lock-owner> <release-directory-id>' < deploy/remote.sh
|
||||
```
|
||||
|
||||
Gebruik de eerder gepinde hostkey en gecontroleerde waarden uit het journal.
|
||||
Verwijder een achtergebleven lege `operation/` alleen als stap 1 heeft bewezen
|
||||
dat de oude operatie gestopt is.
|
||||
5. Bij herstel van de oude release: herstel ook de volledige `before`-state
|
||||
atomisch, inclusief high-watermark en rollbackdoel. Bij een mislukte eerste
|
||||
release: stop de defecte stack en behoud de lege succesvolle state. Verwijder
|
||||
geen volumes of release-tags.
|
||||
6. Noteer het herstel in history, archiveer het opgeloste journal en verwijder
|
||||
daarna pas de lockowner en lege lockdirectory. Start vervolgens een retry.
|
||||
Laat de blokkade staan als gezondheid of state onduidelijk is.
|
||||
|
||||
## Na S16 en lokale verificatie
|
||||
|
||||
S16 blokkeert S6 niet. Zodra S16 is ingevoerd, moeten database/volumes buiten de
|
||||
vervangbare containers blijven en saved data zowel deployment als rollback
|
||||
overleven. De gewone procedure vereist dan databasecompatibiliteit met de vorige
|
||||
applicatieversie. Plan incompatibele migraties apart: imageherstel maakt geen
|
||||
migratie ongedaan. Backup/restore blijven S17-werk.
|
||||
|
||||
```sh
|
||||
java ci/Ci.java test
|
||||
java ci/Ci.java release-test
|
||||
sh -n deploy/remote.sh
|
||||
mvn -N -Dformat.mode=check exec:exec@format
|
||||
```
|
||||
|
||||
De buildworkflow voert beide Java-testsuites uit vóór publicatie. Release-tests
|
||||
wijzigen geen productie, registry of tags. Leg echte NAS-/Gitea-bewijzen vast in
|
||||
`docs/s6-validation.md`; configuratie en lokale tests alleen voltooien S6 niet.
|
||||
70
docs/s18-validation.md
Normal file
70
docs/s18-validation.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# S18 implementation validation
|
||||
|
||||
Validated on 2026-11-03 against the confirmed S18 specification.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- JDK 25 `mvn clean verify`: passed, 52 backend tests and 19 standards-tool
|
||||
regression tests, with no failures or skipped tests.
|
||||
- Clean backend line coverage: 1175/1215 lines, 96.70781893004116%, above the
|
||||
exact 80% gate. No handwritten-code exclusions were introduced.
|
||||
- Frontend `npm test`: 24 tests across six files passed.
|
||||
- Frontend production build and ESLint: passed.
|
||||
- Playwright with the real REST backend: all three scenarios passed, covering school management,
|
||||
shared memberships, automatic scheduling, manual draft editing and finalization,
|
||||
a coach accompanying preschoolers across dates, and connection-error recovery.
|
||||
|
||||
Focused solver tests cover showcase bookends (including extra showcases), single
|
||||
and absent showcases, changing gaps, coach exemptions, infeasibility, the strict
|
||||
priority of showcase attendance over balance, and style/age scores. Lifecycle
|
||||
and HTTP tests cover skipped empty groups, first-show-date ages, retained preferredDayParts after
|
||||
daypart removal, atomic structural validation, invalid drafts, final status
|
||||
invalidation, stale solver results, timeout preservation, coach identity links,
|
||||
and ordered JSON round trips including version-2 migration.
|
||||
|
||||
## Manual standards assessment
|
||||
|
||||
The standards report passes without new waivers. Retained review signals were
|
||||
assessed against `CODING_STANDARDS.md` and `docs/backend-review.md`:
|
||||
|
||||
- `GroupRequirement`, `SchedulingInput`, `Performance` and `ShowView` carry six
|
||||
or seven related snapshot/API values. Collections are defensively copied;
|
||||
coach identity and group membership use IDs across the solver boundary.
|
||||
The larger record shapes make the scheduling contract explicit. Other added
|
||||
records are small. `OrderedAssignments` deliberately owns mutable OR-Tools
|
||||
variables and arrays internally during one solve; it is not an API snapshot.
|
||||
- `ScheduleRules` has cyclomatic signals for independent configuration checks
|
||||
and showcase cases. Each rule family is isolated in a short method and shared
|
||||
between finalization and solver-result validation.
|
||||
- `ShowSchedules` retains branching for nullable initial settings, retained preferredDayParts,
|
||||
and structural draft validation. It stages complete replacement lists before
|
||||
mutation and uses the existing aggregate lock. It separates schedule lifecycle
|
||||
from the already broad `SchoolManagementService` aggregate owner.
|
||||
- The aggregate owner's fan-out and public-method signals are retained for its
|
||||
repository and transaction ownership. Scheduling-input preparation, coach
|
||||
identity operations, lifecycle rules and scoring reside in separate focused
|
||||
classes. Its new revision operation invalidates final status as well as pending
|
||||
solver results.
|
||||
- `SchoolSnapshot` retains orchestration size, fan-out and cognitive signals for
|
||||
validating and reconstructing an entire graph. Import resolves coach/dancer
|
||||
links explicitly, retains order, rejects unknown references, accepts invalid
|
||||
assignment drafts and resets imported final status. Graph construction remains
|
||||
staged before repository mutation.
|
||||
- REST resource fan-out is mainly endpoint annotations, request/view types and
|
||||
domain/service collaborators. OR-Tools stays inside the solver package.
|
||||
`SchedulingInputs` uses domain types to produce one immutable solver snapshot.
|
||||
- `OrToolsSchedulingEngine` retains a length/cognitive signal for the ordered
|
||||
optimization stages and their feasible/optimal/timeout outcomes. Keeping the
|
||||
incumbent and freezing only proven optima preserves strict objective priority
|
||||
and valid results within one total deadline.
|
||||
- Solver objective helpers retain five-parameter review signals where a model,
|
||||
variables, group pair and objective coefficient are needed. Replacing those
|
||||
with additional transient wrapper types would obscure the constraint mapping.
|
||||
Common adjacency penalties are algebraically removed without changing scores.
|
||||
- Test fan-out and scenario length signals reflect full HTTP/import/concurrency
|
||||
workflows. Shared fixture setup is extracted where it aids readability; every
|
||||
executable remains within the blocking limits.
|
||||
|
||||
Public behavior is documented in `show-scheduling-rules.md`, the README and the
|
||||
school JSON format reference. No new standards waiver, threshold change or
|
||||
analysis exclusion is required.
|
||||
122
docs/s6-validation.md
Normal file
122
docs/s6-validation.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# S6 implementation and operational evidence
|
||||
|
||||
Status: IN_PROGRESS. Repository implementation and local validation do not yet
|
||||
establish an operational Synology deployment. No production release tags have
|
||||
been created by this implementation session.
|
||||
|
||||
## Confirmed environment
|
||||
|
||||
- Work is on `feature/S6_deployment`.
|
||||
- Read-only authenticated inspection of the installed Gitea confirmed run fields
|
||||
`path=build.yaml@refs/heads/main`, `status=completed`, `conclusion=success`,
|
||||
`event=push`, `head_branch=main` and `head_sha`.
|
||||
- No remote Git tags existed at the time of that read-only inspection.
|
||||
- The maintainer created the dedicated DSM account `agiledancers-deploy` and
|
||||
confirmed SSH port 22. A separate Ed25519 deployment key was generated outside
|
||||
Git on the mini-pc and installed on the NAS.
|
||||
|
||||
Subsequent live checks confirmed key-based access after removing group/other
|
||||
write permissions from the account home. The maintainer installed the dedicated
|
||||
Docker sudo rule using `deploy/enable-docker.sh`; a subsequent noninteractive
|
||||
check reports `linux/x86_64`. NAS model is **DS224+**, Compose is
|
||||
**v2.20.1-6047-g6817716**, `timeout -k` and `curl` are available, and port **8088**
|
||||
was free. The daemon allows HTTP registry access only through `127.0.0.0/8`.
|
||||
Deployment therefore uses the NAS-local `127.0.0.1:3000` alias of the same Gitea
|
||||
registry, without restarting Gitea/Kimai or changing the shared Docker daemon.
|
||||
|
||||
## NAS setup and real image preparation
|
||||
|
||||
`java ci/Ci.java configure-deployment` succeeded using separate owner-only local
|
||||
pull/release tokens. It provisioned `/volume1/docker/agiledancers-production`,
|
||||
the private IP/port configuration and registry pull login, verified the real NAS
|
||||
preflight, and stored the three deployment secrets and five variables in Gitea.
|
||||
No secret values are recorded here.
|
||||
|
||||
A real preparation probe exercised the production coordinator/API/SSH adapter
|
||||
and NAS script, stopping explicitly at the tag-creation boundary. It retrieved
|
||||
both images for verified main commit
|
||||
`b1544357bf6ea5e1607e1de1fa4872b9510690a8` (Gitea API build run ID **35**), verified
|
||||
revision/platform labels, selected registry digests and passed Compose v2.20
|
||||
configuration validation. It released the deployment lock without creating a
|
||||
Git tag, pending journal, successful release state or application containers.
|
||||
The displayed candidate `v1.0.0` was not allocated as a release.
|
||||
|
||||
| Component | Retrieved digest (NAS registry alias `127.0.0.1:3000/wanjakrah/`) |
|
||||
| --- | --- |
|
||||
| Backend | `sha256:d46af705c527a8eaa506407206cfce927edbc4b774951cf13da8702b5cb8c56d` |
|
||||
| Frontend | `sha256:d6c09ad839b929591878195c7403cbf0bbf24ac6e8d335ef90d72b7982c4ba31` |
|
||||
|
||||
Local probe evidence: `ci/artifacts/deployment/setup.log`, `nas-image-probe.log`
|
||||
and `nas-image-probe.json` (ignored by Git). Retained configuration on the NAS:
|
||||
`releases/image-probe-bbb2a980-e624-4331-adb6-b8fffd51fa5d/`. This is preparation
|
||||
evidence, not a successful production release or Gitea dispatch run.
|
||||
|
||||
## Local checks
|
||||
|
||||
- `java ci/Ci.java test`: **17 passed**, including publication gates and process
|
||||
deadlines.
|
||||
- `java ci/Ci.java release-test`: **32 passed**, including all version increments,
|
||||
input/main/manual gates, verified-build provenance, digest preparation failures,
|
||||
duplicate/stale release rejection, retry, version high-watermark after rollback,
|
||||
automatic recovery, failed recovery/first release, ambiguous state commits,
|
||||
lock contention, annotated-tag identity and registry aliases.
|
||||
- Seven of those release tests execute the actual POSIX NAS script against
|
||||
disposable command adapters, covering image/platform checks, published endpoint
|
||||
requests, retained-configuration mismatch, bounded readiness failure, overlapping
|
||||
replacement fencing and atomic state/journal handling. These are simulated
|
||||
failure-path tests, separate from the real NAS preparation probe above.
|
||||
- `sh -n` passed separately for `deploy/remote.sh`, `deploy/provision.sh` and
|
||||
`deploy/enable-docker.sh`.
|
||||
- `mvn -N -Dformat.mode=check exec:exec@format`: passed with no differences.
|
||||
- All workflow YAML parsed; assertions confirmed automatic builds, manual-only
|
||||
release/rollback triggers and the patch/minor/major form choices.
|
||||
- `docker compose -f deploy/compose.yaml config --quiet` passed with representative
|
||||
digest references; real NAS Compose validation passed during preparation too.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
Backend/frontend application code is unchanged.
|
||||
|
||||
## Full Gitea feature build
|
||||
|
||||
[Feature build #36](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/36)
|
||||
(API run ID **37**) completed successfully for implementation commit
|
||||
`525e9f9335e4343c28f91664c149d24da9284904` on `feature/S6_deployment`. This exercises
|
||||
the full existing verification path, including Maven verification, both helper
|
||||
test suites, frontend checks/build, both Docker image builds and container E2E.
|
||||
Image publication is skipped for the feature branch.
|
||||
|
||||
The subsequent evidence-only commit intentionally uses the accepted `[skip ci]`
|
||||
marker; implementation code is unchanged from this successful run.
|
||||
|
||||
Main build/publication and dispatched production/recovery evidence remain to be
|
||||
collected after review and the protected merge. Review PR:
|
||||
[S6 #12](http://192.168.2.28:3000/wanjakrah/agiledancers/pulls/12).
|
||||
|
||||
## Required operational acceptance
|
||||
|
||||
- [x] Record NAS model/platform, Docker/Compose versions, private endpoint and
|
||||
production directory (no secret values).
|
||||
- [x] Verify pinned SSH hostkey, key-only automation and separate registry pull
|
||||
credential. Release-token read access is verified; actual tag-writing
|
||||
acceptance awaits the first release.
|
||||
- [ ] Verify release-tag creation/protection with the dedicated token.
|
||||
- [ ] Merge workflows through the protected-branch process and identify the
|
||||
successful main build/publication run and SHA.
|
||||
- [ ] Demonstrate release form, first release tag and real Synology access;
|
||||
record workflow URL, version, SHA and both deployed digests.
|
||||
- [ ] Demonstrate rejected unverified/missing-image release and branch exclusion
|
||||
without modifying production.
|
||||
- [ ] Demonstrate duplicate/stale/concurrent release protection, retained tags
|
||||
after failure and exact-SHA retry without another version.
|
||||
- [ ] Demonstrate failed candidate recovery while the workflow reports failure,
|
||||
with downloadable diagnostics.
|
||||
- [ ] Demonstrate manual rollback without a new version and retained image pairs.
|
||||
- [ ] Exercise first-release/recovery failure and interruption/manual recovery
|
||||
using isolated acceptance resources where appropriate.
|
||||
- [ ] Confirm fourteen-day artifact retention.
|
||||
- [ ] Confirm operator communication of the accepted pre-S16 data-loss policy.
|
||||
- [ ] Once S16 applies, verify data preservation and migration compatibility
|
||||
during deployment and rollback (conditional on S16 implementation).
|
||||
|
||||
Identify isolated acceptance resources explicitly. Simulated tests are not real
|
||||
production evidence.
|
||||
89
show-scheduling-rules.md
Normal file
89
show-scheduling-rules.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Show scheduling rules
|
||||
|
||||
A show is the complete event, including every dated daypart. It may span several
|
||||
calendar dates. Counts do not restart on a new date.
|
||||
|
||||
## Required performances
|
||||
|
||||
The manager chooses **preferredDayParts**, the desired number of distinct dayparts, per show.
|
||||
It defaults to two, or one for a show with only one daypart. A show can be saved
|
||||
without dayparts but cannot be scheduled. If dayparts are removed, preferredDayParts is retained
|
||||
and must be corrected before scheduling when it exceeds the remaining count.
|
||||
|
||||
Every nonempty regular, selection and pre-education group performs in exactly preferredDayParts
|
||||
dayparts, once in each. Each preschool group performs exactly once in the whole
|
||||
show. Every showcase performs once in every daypart. Groups without dancers are
|
||||
skipped completely, including for showcase positions and balancing.
|
||||
|
||||
With at least two showcases, one opens and another closes every daypart. Any
|
||||
additional showcases may appear between them. The opening and closing groups can
|
||||
change between dayparts. A single showcase may open or close, but never performs
|
||||
twice in the same daypart. Without showcases there is no opening/closing rule.
|
||||
|
||||
## People and changing clothes
|
||||
|
||||
An ordinary dancer must perform in no more than preferredDayParts distinct dayparts across all
|
||||
of their groups. The same dancer registration must be used in every group.
|
||||
|
||||
Showcase participants necessarily perform in every daypart. For their other
|
||||
performances, exceeding preferredDayParts dayparts is allowed but discouraged. We count their
|
||||
non-showcase dayparts, subtract preferredDayParts, and count only positive excesses. The planner's
|
||||
first preference is to minimize the sum of these excesses across dancers.
|
||||
|
||||
A coach registration must be explicitly linked to the corresponding dancer;
|
||||
names are not used to guess identity. Coaches are available throughout the show
|
||||
and are exempt from both the preferredDayParts limit and the excess penalty. A coach only performs
|
||||
with a group if included in its dancer membership. Being a group's coach alone
|
||||
never adds a performance or forces that group into extra dayparts.
|
||||
|
||||
Between any two performances involving the same person, at least two other
|
||||
performances must intervene in which that person does not dance. This includes
|
||||
coaches accompanying preschoolers, coaches dancing in other groups, and showcases.
|
||||
There are no exceptions. Between dayparts we assume sufficient changing time,
|
||||
so the gap rule restarts even if the dayparts are on the same date.
|
||||
|
||||
## Balance and preferences
|
||||
|
||||
All performances count equally, including preschool groups and showcases. The
|
||||
busiest daypart may have at most two more performances than the quietest. Zero
|
||||
is ideal. Preschool groups can cause a difference but have no special exemption.
|
||||
|
||||
Among schedules satisfying every mandatory rule, optimize in this strict order:
|
||||
|
||||
1. Minimize non-showcase daypart excess for showcase participants, excluding coaches.
|
||||
2. Minimize the difference in performance counts between dayparts.
|
||||
3. Minimize similarity of consecutive performances within each daypart.
|
||||
|
||||
A lower priority cannot worsen a higher priority. OR-Tools solves these objectives
|
||||
in sequence, fixing each proven optimum before working on the next. If it finds
|
||||
only a feasible result at a priority, it returns that valid result without
|
||||
claiming that the complete schedule is optimal.
|
||||
|
||||
For variety, style similarity is the fraction of shared styles among all distinct
|
||||
styles of the two groups. Two groups without styles have no style penalty.
|
||||
Age similarity is one for equal average ages, falling linearly to zero at a
|
||||
five-year difference and remaining zero above that difference. Style and age
|
||||
have equal weight. Each component is rounded to millionths for CP-SAT's integer
|
||||
objective. Showcases count in this comparison. Average age includes every dancing
|
||||
member equally, including coaches, measured in completed years on the first date
|
||||
of the show. Empty groups are already excluded.
|
||||
|
||||
## Results, drafts and final schedules
|
||||
|
||||
Scheduling can take up to thirty minutes in total across the optimization stages.
|
||||
A feasible result is accepted even when optimality is unproven. Every mandatory
|
||||
rule still applies. Proven infeasibility and reaching the time limit without a
|
||||
result are reported differently. Failures preserve the saved assignments and
|
||||
order. Concurrent runs are rejected and changed inputs invalidate pending results.
|
||||
|
||||
Automatic results are drafts for manager review. Managers can also move, add,
|
||||
remove and reorder performances manually and save incomplete or invalid drafts.
|
||||
The application lists the rules those drafts violate. An explicit finalization
|
||||
action checks all mandatory rules; only a valid schedule can become final.
|
||||
Preferences alone never block finalization.
|
||||
|
||||
Manual edits and relevant membership, dancer-age, coach-link or show-setting
|
||||
changes return final schedules to draft. Existing schedules and imported schedules
|
||||
are drafts. Import retains assignment order and recalculates violations. Export
|
||||
is available for both statuses and records status, preferredDayParts and coach identity links;
|
||||
reimport always requires a fresh finalization.
|
||||
@@ -0,0 +1,5 @@
|
||||
As DanceSchool Maintainer
|
||||
I want the ability to exclude certain groups for a specific show
|
||||
so that I can diversify in the groups and shows that we offer.
|
||||
|
||||
State: NEW
|
||||
@@ -1,4 +1,4 @@
|
||||
# School JSON format, version 1
|
||||
# School JSON format, version 3
|
||||
|
||||
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
|
||||
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
|
||||
@@ -11,19 +11,19 @@ There is no merge endpoint.
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"formatVersion": 3,
|
||||
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
|
||||
"name": "Studio",
|
||||
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
|
||||
"coaches": [{ "id": "c1", "name": "Alex" }],
|
||||
"coaches": [{ "id": "c1", "name": "Alex", "dancerId": "d1" }],
|
||||
"groups": [{
|
||||
"id": "g1", "category": "Hiphop", "type": "REGULAR",
|
||||
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
|
||||
"dancerIds": ["d1"], "coachId": "c1"
|
||||
}],
|
||||
"shows": [{
|
||||
"id": "s1", "name": "Summer show", "date": "2027-06-12",
|
||||
"dayParts": [{ "type": "MORNING", "groupIds": ["g1"] }]
|
||||
"id": "s1", "name": "Summer show", "preferredDayParts": 1, "finalized": false,
|
||||
"dayParts": [{ "date": "2027-06-12", "type": "MORNING", "groupIds": ["g1"] }]
|
||||
}]
|
||||
}
|
||||
```
|
||||
@@ -32,14 +32,20 @@ There is no merge endpoint.
|
||||
inside each collection are nonblank strings, unique within that collection, and
|
||||
local to this file. They can change on export. References must resolve within the
|
||||
file. A dancer or coach can be referenced by multiple groups. A group can appear
|
||||
in multiple day parts. A day-part type can occur once per show.
|
||||
in multiple day parts. A date/type combination can occur once per show, with at most six dayparts.
|
||||
|
||||
All fields are required except `coachId`, which may be absent or null. Collections
|
||||
The optional `coachId` and coach `dancerId` references may be absent or null.
|
||||
`preferredDayParts` defaults to two (one for a single daypart); `finalized`
|
||||
defaults to false. Legacy version 2 files remain supported; version 1 is rejected.
|
||||
Legacy `appearances` counts may be present but do not govern S18 scheduling.
|
||||
Every imported schedule becomes a draft, even when exported as final.
|
||||
`preferredDayParts` must be an integer from one to six; a retained value exceeding
|
||||
the daypart count must be corrected before scheduling or finalization. Collections
|
||||
may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`;
|
||||
birth dates cannot be in the future. Ages are nonnegative integers with
|
||||
`startAge <= endAge`. Enum values use the same uppercase names as the management
|
||||
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`.
|
||||
Unknown fields, duplicate JSON properties, duplicate IDs or references, invalid
|
||||
Unknown fields, duplicate JSON properties, duplicate IDs or membership references, invalid
|
||||
values and unsupported versions are rejected. Validation is repeated on commit.
|
||||
The entire school is prepared before publishing it under the shared service lock.
|
||||
Replacing retains the numeric school ID for existing browser links; imported
|
||||
|
||||
184
specs/in_progress/S6_deployment-gitea-action-to-production.md
Normal file
184
specs/in_progress/S6_deployment-gitea-action-to-production.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# S6 — Manual versioned production deployment through Gitea
|
||||
|
||||
Status: IN_PROGRESS
|
||||
|
||||
Agreed after the grill-me interview; shared understanding confirmed.
|
||||
|
||||
## User story
|
||||
|
||||
As a developer, I want to manually release a verified main-branch build from
|
||||
Gitea to the Synology, choosing a semantic version increment, so that I control
|
||||
when the launching partner receives a new production version and can recover
|
||||
the previous working version when necessary.
|
||||
|
||||
This replaces the original automatic-deployment story. Build, test and image
|
||||
publication remain automatic; production deployment requires a deliberate action.
|
||||
|
||||
## Existing foundation and scope
|
||||
|
||||
- Extend the implemented S4 pipeline, which verifies builds and publishes matching
|
||||
frontend/backend images with immutable full commit SHA tags for successful main
|
||||
builds. Deploy those existing images without rebuilding them.
|
||||
- Target one production Docker Compose stack on the Synology (`linux/amd64`).
|
||||
The existing Gitea runner triggers deployment over SSH.
|
||||
- Keep one active backend. A brief restart interruption is acceptable, including
|
||||
interruption of a scheduling calculation; the user may restart the calculation.
|
||||
- Production is private to the LAN or existing VPN. A configurable Synology
|
||||
hostname/IP and port provide access without an application login.
|
||||
- Publish only the frontend port. Keep the backend reachable within the Compose
|
||||
network; route browser API requests through the frontend proxy.
|
||||
- Actual server addresses, ports, paths and account configuration must be
|
||||
established and documented during implementation, without committing secrets.
|
||||
|
||||
## Manual release interface
|
||||
|
||||
Provide a separate **Deploy production** workflow using Gitea's native manual
|
||||
workflow trigger (`workflow_dispatch`). Its Run workflow popup offers:
|
||||
|
||||
- **New release** or **Retry release** mode.
|
||||
- For a new release: a release-type choice of **patch**, **minor** or **major**.
|
||||
- For a retry: the existing release version tag.
|
||||
|
||||
Provide a manual rollback action for the previous successful production release.
|
||||
The native Gitea button need not be renamed or embedded into an existing build
|
||||
run. Gitea 1.25.4 supports the required modal and choice inputs.
|
||||
|
||||
### New release eligibility and ordering
|
||||
|
||||
- Run the release workflow from main. Branch builds cannot deploy production.
|
||||
- Pin the current main SHA at workflow start. Require successful build checks and
|
||||
image publication for that exact SHA; missing or failed verification leaves
|
||||
production unchanged. Both images must be retrievable before replacement.
|
||||
- Recheck main immediately before changing production. If it has advanced, abort
|
||||
without changing production. Once replacement starts, finish deployment or
|
||||
recovery using the pinned SHA; later commits require another manual release.
|
||||
- Serialize deployment, recovery, rollback, version allocation and tag creation.
|
||||
Prevent stale runs from replacing newer successful production releases.
|
||||
- Reject a new release for an already release-tagged commit and direct the
|
||||
operator to retry or rollback instead. Repeated clicks must not create extra
|
||||
versions or deploy out of order.
|
||||
|
||||
## Semantic versioning and Git tags
|
||||
|
||||
- Use one shared application version for frontend and backend, represented by
|
||||
an immutable annotated Git tag `vMAJOR.MINOR.PATCH` on the verified SHA.
|
||||
- The first release is `v1.0.0`. Inspect remote tags before initialization and
|
||||
fail visibly on conflicting existing history instead of overwriting it.
|
||||
- Subsequent releases increment the highest existing release version tag,
|
||||
including tags belonging to failed deployments. A rollback does not lower the
|
||||
version baseline. Patch increments PATCH; minor increments MINOR and resets
|
||||
PATCH; major increments MAJOR and resets MINOR and PATCH.
|
||||
- For example, from `v1.2.3`, the choices produce `v1.2.4`, `v1.3.0` or `v2.0.0`.
|
||||
- After checking eligibility and image availability, create the tag before
|
||||
changing production. This establishes tag-writing access before replacement.
|
||||
Never move a release tag or reuse its version for different code.
|
||||
- Retain a tag if deployment subsequently fails or aborts. Record the outcome so
|
||||
a tag is not mistaken for proof of successful production deployment.
|
||||
- A retry reuses the existing tag and exact SHA without allocating a version.
|
||||
Require successful build verification and matching available images. Retrying
|
||||
an older release must not supersede a newer successful release. Retrying an
|
||||
existing release is an explicit exception to the current-main requirement.
|
||||
- Rollback restores an existing release and creates no new version. It is an
|
||||
explicit exception to the prohibition on replacing newer code with older code.
|
||||
- Continue using SHA-tagged container images; the Git release tag and deployment
|
||||
record map the semantic version to that SHA and image pair.
|
||||
- Maven/npm version fields and automated changelog generation are outside S6.
|
||||
Do not introduce a version-bump commit and rebuild into the release path.
|
||||
|
||||
## Deployment checks and recovery
|
||||
|
||||
- Retrieve the candidate images and validate prerequisites before replacing the
|
||||
running release. Frontend and backend must use the same selected commit SHA.
|
||||
- Within five minutes of starting the replacement, require all of:
|
||||
- Backend readiness succeeds.
|
||||
- The frontend page is served.
|
||||
- A read-only API request succeeds through the frontend proxy.
|
||||
- Both running containers match the selected SHA/image pair.
|
||||
- Checks must not create or modify user data. Log the selected SHA/version and
|
||||
final outcome.
|
||||
- A failed candidate automatically restores the previous successful image pair
|
||||
and checks its health. The workflow remains failed even if recovery succeeds.
|
||||
- Retain references to the current and previous successful releases and ensure
|
||||
their images remain available. A manual rollback targets that previous release;
|
||||
fail before modifying production if its required images cannot be retrieved.
|
||||
- If the first deployment fails with no previous release, or recovery itself
|
||||
fails, stop with a clear failure report and manual recovery instructions.
|
||||
Preserve diagnostics; do not enter an endless deployment/recovery loop.
|
||||
- Record successful production release state separately from allocated Git tags,
|
||||
so failed attempts do not become the rollback target.
|
||||
|
||||
## Data policy and S16/S17 boundaries
|
||||
|
||||
S16 is **not a prerequisite** for S6. Until S16 is implemented, application state
|
||||
is held in memory and may be lost during deployment, rollback or recovery. The
|
||||
launching-partner phase explicitly accepts this limitation. Restoring old images
|
||||
does not restore lost data.
|
||||
|
||||
- State the temporary data-loss limitation in the workflow description and
|
||||
deployment runbook. The operator is responsible for informing the launching
|
||||
partner; do not add a further approval step to each deployment.
|
||||
- After S16 is implemented, deployments and rollbacks must preserve saved data.
|
||||
Keep persistent storage outside replaceable containers and do not delete it
|
||||
during deployment or recovery.
|
||||
- After S16, ordinary release and rollback paths require database compatibility
|
||||
with the previous application version. Incompatible migrations require a
|
||||
separately planned maintenance procedure; image rollback cannot undo them.
|
||||
- Database backup and restore remain separate S17 work. S6 does not promise
|
||||
recovery of data lost before persistence or through storage loss.
|
||||
|
||||
## Credentials and operational documentation
|
||||
|
||||
- Use a dedicated Synology deployment account with SSH key authentication and a
|
||||
pinned server host key. Document the narrowly required deployment permissions.
|
||||
- Use a separate registry credential with pull access for production and a
|
||||
credential with the required repository permission for release-tag creation.
|
||||
- Store deployment secrets in Gitea Actions secrets, provision server-side
|
||||
credentials securely where needed, and keep secret values out of Git and logs.
|
||||
- Document initial provisioning, configuration, release/retry/rollback operation,
|
||||
credential setup, health checks, release records and manual recovery.
|
||||
- Retain deployment diagnostics for fourteen days, including failed deployment
|
||||
and recovery outcomes, without exposing credentials.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Successful main builds still automatically verify and publish matching SHA
|
||||
images; publishing does not automatically change production.
|
||||
- [ ] Gitea's manual workflow form provides new/retry mode and the appropriate
|
||||
release-type or existing-tag inputs; invalid combinations fail clearly.
|
||||
- [ ] The first release and subsequent patch/minor/major releases allocate the
|
||||
agreed versions and immutable annotated remote Git tags on verified SHAs.
|
||||
- [ ] Duplicate new releases, tag conflicts, concurrent attempts, stale main
|
||||
snapshots and older pending deployments cannot overwrite release identity
|
||||
or replace a newer successful release.
|
||||
- [ ] An unverified release or missing image is rejected before production changes;
|
||||
branch builds cannot deploy production.
|
||||
- [ ] A real Synology deployment serves the frontend and proxied API over the
|
||||
private endpoint, with both containers running the selected image pair.
|
||||
- [ ] Readiness, frontend, proxied read-only API and image-identity checks establish
|
||||
success within five minutes or trigger recovery.
|
||||
- [ ] A deliberately failed release restores the previous successful version,
|
||||
while the workflow reports failure. Failed first deployment and failed
|
||||
recovery produce actionable diagnostics and manual recovery instructions.
|
||||
- [ ] A failed release can be retried with its existing tag/SHA without allocating
|
||||
another version or superseding a newer successful release.
|
||||
- [ ] Manual rollback demonstrably restores the previous successful release with
|
||||
no new version, and required retained images are available.
|
||||
- [ ] The temporary pre-S16 data-loss policy is visible in workflow documentation
|
||||
and the runbook. Once S16 applies, demonstrate saved data surviving both
|
||||
deployment and rollback, including the migration compatibility requirement.
|
||||
- [ ] Server setup and operational recovery documentation are usable; diagnostics
|
||||
and release outcomes are retained for fourteen days without secrets.
|
||||
|
||||
## Implementation handoff
|
||||
|
||||
This specification records the confirmed design. Implementing workflows, release
|
||||
automation, Compose configuration, Synology provisioning and collecting actual
|
||||
deployment/recovery evidence are subsequent work. Configuration alone does not
|
||||
complete S6.
|
||||
|
||||
Implementation is in progress on `feature/S6_deployment`: workflows, Compose,
|
||||
release/retry/rollback helpers, failure-path tests and provisioning/runbook are
|
||||
provided. The dedicated NAS account, registry login and Gitea deployment secrets
|
||||
are provisioned. Real image preparation passed without a release or container
|
||||
replacement. See `docs/s6-validation.md`; workflow execution and production
|
||||
deployment/recovery evidence remain required after the protected main merge.
|
||||
@@ -0,0 +1,145 @@
|
||||
# S18 — Scheduling dance groups across a show
|
||||
|
||||
Status: IN_REVIEW
|
||||
|
||||
As a dance school manager,
|
||||
I want OR-Tools to schedule groups and order performances using participation,
|
||||
changing-time and variety rules,
|
||||
so that the complete show is manageable for dancers and enjoyable for audiences.
|
||||
|
||||
Refinement confirmed by the maintainer on 2026-11-03. This specification records
|
||||
the agreed design. Implementation and validation are complete and ready for review.
|
||||
See [validation evidence](../../docs/s18-validation.md) and
|
||||
[the applied scheduling rules](../../show-scheduling-rules.md).
|
||||
|
||||
## Show scope and configuration
|
||||
|
||||
- A show consists of dayparts and can span multiple dates, typically two days.
|
||||
All counts below apply across the entire show, not separately per date.
|
||||
- Each show has one configurable integer `preferredDayParts`, between 1 and the number of
|
||||
dayparts inclusive. Its default is 2, or 1 for a one-daypart show.
|
||||
- This replaces the existing configurable appearance counts per group type.
|
||||
- A show without dayparts can be stored but cannot be scheduled.
|
||||
- If reducing the number of dayparts makes `preferredDayParts` invalid, require the user to
|
||||
correct it before scheduling again; do not silently change it.
|
||||
- Groups without dancers are excluded from scheduling and from all performance
|
||||
counts and showcase-position requirements.
|
||||
|
||||
## Required group appearances
|
||||
|
||||
- Each nonempty REGULAR, SELECTION and PRE_EDUCATION group performs in exactly
|
||||
`preferredDayParts` distinct dayparts, once within each selected daypart.
|
||||
- Each nonempty PRE_SCHOOLER group performs exactly once in the complete show,
|
||||
regardless of `preferredDayParts`. At most two such groups are expected. They can be assigned
|
||||
freely to dayparts; no additional placement restriction applies.
|
||||
- Each nonempty SHOW_CASE group performs exactly once in every daypart.
|
||||
- With at least two showcase groups, each daypart starts with one showcase and
|
||||
ends with another. Additional showcases can appear anywhere between them.
|
||||
Opening and closing groups may differ between dayparts.
|
||||
- With exactly one showcase group, it appears either first or last in each
|
||||
daypart; the planner chooses. It does not perform twice in that daypart.
|
||||
- With no showcase groups, there is no showcase-position requirement.
|
||||
|
||||
## Dancer participation and coaches
|
||||
|
||||
- A dancer without a showcase membership performs in at most `preferredDayParts` distinct
|
||||
dayparts across the complete show. This is a hard constraint.
|
||||
- A showcase participant necessarily performs in all dayparts. Their other
|
||||
group performances may span more than `preferredDayParts` dayparts, but this is undesirable.
|
||||
- For each non-coach showcase participant, count the distinct dayparts in which
|
||||
they perform outside showcases. Their excess is `max(0, count - preferredDayParts)`.
|
||||
Minimize the sum of these excesses as the first optimization priority.
|
||||
- A person dancing in several groups must use the same dancer registration in
|
||||
every group, including when accompanying preschoolers. Up to five group
|
||||
memberships per dancer are expected.
|
||||
- Explicitly link a coach registration to the corresponding dancer registration;
|
||||
never infer identity from matching names. Support maintaining this link.
|
||||
- Coaches are available in all dayparts and are excluded from both the hard
|
||||
dancer limit and the excess penalty, including coaches in showcases.
|
||||
- Being assigned as a group's coach does not automatically mean performing in
|
||||
that group. Actual performances are determined by dancer membership only.
|
||||
Coach availability never increases a group's required appearances.
|
||||
- Dancing coaches remain subject to changing-time rules and contribute to the
|
||||
group's average age just like other dancing members.
|
||||
|
||||
## Changing time and feasibility
|
||||
|
||||
- Between any two performances involving the same dancer within a daypart,
|
||||
there must be at least two intervening performances not involving that dancer.
|
||||
- This is mandatory for everyone, including coaches and showcase participants.
|
||||
- The rule restarts at each daypart boundary. Assume enough changing time
|
||||
between dayparts, including on the same date.
|
||||
- All hard constraints apply together, even for unusual membership combinations
|
||||
such as a person dancing in both preschool and ordinary groups. Do not relax
|
||||
a rule to obtain a result.
|
||||
|
||||
## Balance and optimization priorities
|
||||
|
||||
- Count all scheduled performances, including preschool and showcase groups.
|
||||
- The difference between the largest and smallest daypart performance counts
|
||||
must not exceed two. This is a hard constraint.
|
||||
- Prefer a difference of zero. Preschool groups are an expected cause of
|
||||
differences but have no special exemption or exclusive right to cause them.
|
||||
- Optimize valid schedules in this strict priority order:
|
||||
1. Minimize the total excess for non-coach showcase participants defined above.
|
||||
2. Minimize the difference between largest and smallest daypart counts.
|
||||
3. Improve style and age variety between consecutive performances within
|
||||
each daypart.
|
||||
- A lower priority must not worsen a higher priority. Showcases participate in
|
||||
variety scoring as well.
|
||||
|
||||
## Variety scoring
|
||||
|
||||
- For adjacent groups, the style penalty is the number of shared styles divided
|
||||
by the number of distinct styles in their combined style sets. No shared
|
||||
styles gives zero penalty; two empty style sets also give zero penalty.
|
||||
- Compute dancer ages on the first date of the show. All dancers in a group,
|
||||
including dancing coaches, contribute equally to its average age.
|
||||
- The age penalty decreases linearly from 1 for equal average ages to 0 for a
|
||||
difference of five years; differences greater than five years also score 0.
|
||||
Equivalently: `max(0, 1 - abs(meanAgeA - meanAgeB) / 5)`.
|
||||
- Style and age penalties have equal weight. Minimize their combined penalties
|
||||
over adjacent performances. Empty groups have already been excluded.
|
||||
|
||||
## Solver execution and failure
|
||||
|
||||
- Retain a maximum solver runtime of thirty minutes.
|
||||
- Accept a feasible result without proof of optimality, provided every hard
|
||||
constraint holds. Distinguish feasibility from proven optimality.
|
||||
- If no valid result is found, preserve the existing assignments and order.
|
||||
- Explain whether infeasibility was proven or the time limit expired without
|
||||
a valid result; do not report a timeout as proof of infeasibility.
|
||||
- Retain existing protections against concurrent scheduling and applying stale
|
||||
results after relevant data changes.
|
||||
|
||||
## Manual editing and schedule lifecycle
|
||||
|
||||
- Add UI and API support for moving performances between dayparts and changing
|
||||
their order. Partial or temporarily invalid assignments can be saved as drafts.
|
||||
- Distinguish draft and final schedules explicitly. Display concrete violations
|
||||
for invalid drafts; do not present them as valid final schedules.
|
||||
- Add an explicit finalization action that checks all hard rules and permits
|
||||
finalization only when they all pass. Optimization preferences do not block it.
|
||||
- Automatically generated valid schedules also start as drafts for manager review.
|
||||
- Manual changes to a final schedule return it to draft. Changes to relevant
|
||||
dancer memberships, coach identity links or show settings likewise invalidate
|
||||
final status when they affect scheduling rules.
|
||||
- Treat existing schedules as drafts when introducing this lifecycle.
|
||||
- JSON import preserves assignments and order but makes imported schedules
|
||||
drafts and validates them against the new rules.
|
||||
- JSON export remains available for drafts and finals and preserves status.
|
||||
Import still produces a draft even when the exported status was final.
|
||||
- Include the new configuration and coach identity information in persistence
|
||||
and school JSON round trips so scheduling semantics survive saving and import.
|
||||
|
||||
## Documentation and validation
|
||||
|
||||
- Provide `show-scheduling-rules.md` describing the rules applied by OR-Tools in
|
||||
plain English, including hard constraints, exceptions, optimization priorities,
|
||||
scoring, cross-date scope and failure behavior.
|
||||
- Validate automatic results and finalization using the same agreed rules.
|
||||
- Verification must cover ordinary, preschool and showcase appearances; coach
|
||||
identity and exemptions; overlapping memberships; changing gaps; balance;
|
||||
optimization priority and scoring; multi-date shows; empty groups; invalid
|
||||
configuration; infeasible and timed-out runs; manual drafts/finalization; and
|
||||
persistence/import/export of the new information.
|
||||
118
specs/refined/S16_store-state-in-persistent-storage
Normal file
118
specs/refined/S16_store-state-in-persistent-storage
Normal file
@@ -0,0 +1,118 @@
|
||||
# S16 — Store state in persistent storage
|
||||
|
||||
Status: REFINED
|
||||
|
||||
Agreed after the grill-me interview; shared understanding confirmed.
|
||||
|
||||
## User story
|
||||
|
||||
As a dance school maintainer, I want application data to be stored persistently,
|
||||
so that confirmed changes and saved scheduling results survive server restarts
|
||||
and application crashes.
|
||||
|
||||
## Storage decision
|
||||
|
||||
Use PostgreSQL with relational tables for schools, dancers, coaches, groups,
|
||||
shows and their relationships. Use Flyway to version and apply database schema
|
||||
changes.
|
||||
|
||||
The existing domain has shared relationships and operations on individual school
|
||||
components. Related changes must be atomic, and multiple maintainers must be able
|
||||
to work without silently overwriting each other's changes. These requirements
|
||||
favor relational storage with transactions and referential constraints.
|
||||
|
||||
A document database was considered: a whole-school document would align with
|
||||
JSON import/export. However, the agreed requirements provide no concrete benefit
|
||||
for that approach over relational storage. The application owns a fixed domain
|
||||
model; school-defined arbitrary fields and structures are not required.
|
||||
|
||||
JSON remains the import/export format, rather than the primary storage model.
|
||||
|
||||
## Scope and deployment
|
||||
|
||||
- Support multiple schools and concurrent maintainers.
|
||||
- Target one production environment with one active backend instance.
|
||||
- A separate database service and its operational management are acceptable.
|
||||
- Multiple active backend instances, distribution across servers or regions, and
|
||||
automatic resumption of calculations are outside S16.
|
||||
- Keep the implementation within existing project architecture and coding
|
||||
standards; this specification grants no standards waiver.
|
||||
|
||||
## Durability and consistency
|
||||
|
||||
- Confirm a mutation as successful only after its database transaction commits
|
||||
durably. All confirmed domain changes and saved scheduling results survive an
|
||||
application restart or crash.
|
||||
- Persist schools and all their maintained domain data, relationships and saved
|
||||
planning state, rather than only a subset of the existing in-memory state.
|
||||
- Related mutations either succeed completely or leave the previous state intact.
|
||||
This includes deleting a dancer and cleaning up memberships, deleting a group
|
||||
and cleaning up show references, deleting a school and its children, and
|
||||
replacing a school through import.
|
||||
- Validate imports before publishing the replacement. Preserve the current
|
||||
import/export behavior and identity semantics.
|
||||
- Enforce valid relationships through database constraints alongside application
|
||||
validation.
|
||||
|
||||
## Concurrent changes and scheduling
|
||||
|
||||
- Use one persistent revision per school for optimistic concurrency control.
|
||||
- Mutations must identify the school revision on which they are based. Check
|
||||
that revision and commit the change and new revision atomically.
|
||||
- Reject stale mutations with a clear conflict message; do not silently overwrite
|
||||
newer changes. The user can reload current data and decide again.
|
||||
- Changes to different components of the same school may therefore conflict.
|
||||
Finer-grained conflict detection is outside S16.
|
||||
- Retain the scheduling input revision check: a result computed from an outdated
|
||||
input revision must not replace current planning state.
|
||||
- After restart, calculations interrupted by the restart must be recognizably
|
||||
interrupted, rather than remain indefinitely marked as running. They do not
|
||||
resume automatically. Previously saved results remain available.
|
||||
|
||||
## Database failures and schema changes
|
||||
|
||||
- Refuse mutations while the database is unavailable and provide a clear failure
|
||||
message. Do not acknowledge successful storage or fall back to volatile writes.
|
||||
- Connection and Flyway migration failures at startup must be clearly visible;
|
||||
the application must not present itself as ready for normal use when required
|
||||
database initialization has failed.
|
||||
- Maintain the database schema through versioned Flyway migrations.
|
||||
|
||||
## Transition from in-memory storage
|
||||
|
||||
Start with an empty database. Existing data is transferred through the current
|
||||
JSON export/import path; no separate in-memory migration mechanism is required.
|
||||
Document that existing data must be exported before stopping the old application
|
||||
for the transition, because its current state exists only in memory.
|
||||
|
||||
## Deferred work
|
||||
|
||||
Database backup and restore are outside S16 and will be specified separately in
|
||||
`specs/S17_database-backup-and-restore`. That specification is intentionally empty
|
||||
for now. S16 does not promise recovery after loss of the database or its volume.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] PostgreSQL stores the complete maintained domain state and saved scheduling
|
||||
results, with Flyway managing the relational schema.
|
||||
- [ ] Confirmed changes remain available after both a normal restart and an
|
||||
application crash, using the same persistent database.
|
||||
- [ ] Failed related mutations and failed school replacement imports leave no
|
||||
partial changes or dangling references.
|
||||
- [ ] Two edits based on the same school revision cannot silently overwrite one
|
||||
another: the stale edit receives a clear conflict response, and the user
|
||||
can reload and retry.
|
||||
- [ ] Scheduling results based on outdated input revisions are rejected.
|
||||
- [ ] Interrupted calculations are recognizably interrupted after restart;
|
||||
previously saved results remain available.
|
||||
- [ ] Database unavailability prevents successful mutation acknowledgements;
|
||||
startup connection and migration failures are clearly reported.
|
||||
- [ ] An empty database can be initialized through Flyway and populated through
|
||||
existing JSON import, preserving import/export behavior and identities.
|
||||
- [ ] Setup and transition instructions cover the database service, persistent
|
||||
storage, connection configuration, migrations and export-before-transition.
|
||||
|
||||
## Implementation handoff
|
||||
|
||||
This specification records the agreed design and behavior. Database provisioning,
|
||||
application changes and validation are subsequent implementation work.
|
||||
Reference in New Issue
Block a user