10 Commits

Author SHA1 Message Date
b0d6592761 Merge pull request 'S6: manual versioned Synology deployment' (#12) from feature/S6_deployment into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m6s
Reviewed-on: #12
2026-11-03 14:51:13 +01:00
60496de8bb Record successful S6 feature build validation [skip ci] 2026-11-03 14:48:59 +01:00
525e9f9335 Implement manual versioned Synology deployments for S6
All checks were successful
Build, test and publish / verify (push) Successful in 6m34s
2026-11-03 14:16:52 +01:00
c9db528756 refinement of spec S6
All checks were successful
Build, test and publish / verify (push) Successful in 5m49s
2026-11-03 13:30:47 +01:00
b1544357bf Merge pull request 'ortools-documentation' (#11) from ortools-documentation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #11
2026-11-03 13:09:09 +01:00
3a5203eb47 some renaming of variables
All checks were successful
Build, test and publish / verify (push) Successful in 5m55s
2026-11-03 12:58:15 +01:00
a7f7730ea0 S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
2026-11-03 11:03:47 +01:00
c965f9b41d Merge pull request 'refinement2' (#10) from refinement2 into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m45s
Reviewed-on: #10
2026-11-01 21:29:04 +01:00
8fc430537b refinement S16 - database storage
All checks were successful
Build, test and publish / verify (push) Successful in 5m26s
2026-11-01 21:28:42 +01:00
fc307de64e backlog management
All checks were successful
Build, test and publish / verify (push) Successful in 5m38s
2026-11-01 20:51:27 +01:00
76 changed files with 4883 additions and 258 deletions

View 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

View 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

View File

@@ -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`), Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `appearances` for every group type. Omitting counts dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
initializes defaults on creation or the initial daypart configuration; later one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
edits preserve saved counts and reject counts outside 1..N. With no dayparts, dayparts; preschool groups once; showcases every daypart. See
shows remain unscheduled and saved counts are retained. [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 `POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
assign every current school group to its required number of distinct dayparts, draft using OR-Tools with a total thirty-minute time limit. A feasible result is
minimizing the busiest/quietest appearance gap. It replaces assignments only on accepted without claiming optimality. Concurrent runs and stale results return
a valid, current result. The response distinguishes optimal balance from a valid `409`; failures retain existing assignments. Development and production proxies
result whose optimality is unproven. Concurrent runs return `409`; changed inputs allow 31 minutes.
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.
School JSON exports use format version **2**, including dated dayparts, assignments, `PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
and all appearance counts. Version 1 files are intentionally unsupported. 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: 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) image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status. 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) ### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual Maven is the authoritative formatting path. The root build runs the actual

View File

@@ -4,6 +4,7 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import lombok.ToString; import lombok.ToString;
@Getter @Getter
@@ -13,4 +14,7 @@ import lombok.ToString;
public class Coach extends AbstractEntity { public class Coach extends AbstractEntity {
@ToString.Include @ToString.Include
private final String name; private final String name;
@Setter
private Dancer dancer;
} }

View File

@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
@Setter @Setter
private String name; private String name;
@Setter
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter @Setter
private Map<DanceGroupType, Integer> appearances; private Map<DanceGroupType, Integer> appearances;

View File

@@ -1,3 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling; 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);
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -1,9 +1,27 @@
package nl.agilecc.agiledancers.model.scheduling; package nl.agilecc.agiledancers.model.scheduling;
import java.util.List; 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 { public SchedulingInput {
groups = List.copyOf(groups); 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());
} }
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -20,11 +20,13 @@ import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest; 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.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView; import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService; import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
@Path("/api/dance-schools/{schoolId}/shows") @Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -34,33 +36,60 @@ public class ShowResource {
private @Inject SchedulingService scheduling; private @Inject SchedulingService scheduling;
private @Inject ShowSchedules schedules;
private @PathParam("schoolId") long schoolId; private @PathParam("schoolId") long schoolId;
@GET @GET
public List<ShowView> list() { public List<ShowView> list() {
return service.shows(schoolId).stream().map(ShowView::of).toList(); return service.shows(schoolId).stream().map(this::view).toList();
} }
@GET @GET
@Path("/{id}") @Path("/{id}")
public ShowView get(final @PathParam("id") long id) { public ShowView get(final @PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id)); return view(service.show(schoolId, id));
} }
@POST @POST
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) { 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() return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString()) .path(entity.getId().toString())
.build()) .build())
.entity(ShowView.of(entity)) .entity(view(entity))
.build(); .build();
} }
@PUT @PUT
@Path("/{id}") @Path("/{id}")
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) { 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) { private static List<DayPart> parts(final ShowRequest request) {
@@ -79,7 +108,7 @@ public class ShowResource {
public SchedulingView schedule(final @PathParam("id") long id) { public SchedulingView schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id); final SchedulingResult result = scheduling.schedule(schoolId, id);
return new SchedulingView( 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 @DELETE

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
public record CoachRequest(@NotNull Long dancerId) {}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest( public record ShowRequest(
@NotBlank String name, @NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) { Map<DanceGroupType, Integer> appearances,
Integer preferredDayParts) {
public ShowRequest { public ShowRequest {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));

View File

@@ -10,13 +10,25 @@ import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; 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 { public ShowView {
violations = List.copyOf(violations);
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
} }
public static ShowView of(final Show show) { 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( return new ShowView(
show.getId(), show.getId(),
show.getName(), show.getName(),
@@ -28,6 +40,9 @@ public record ShowView(long id, String name, List<DayPartView> dayParts, Map<Dan
.map(DanceGroup::getId) .map(DanceGroup::getId)
.toList())) .toList()))
.toList(), .toList(),
show.getAppearances()); show.getAppearances(),
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
show.isFinalized(),
violations);
} }
} }

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show; 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.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -41,7 +41,8 @@ public class SchoolManagementService {
private final Map<Long, Long> revisions = new HashMap<>(); private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>(); private final Set<Long> running = new HashSet<>();
private void touch(final Show show) { public synchronized void touch(final Show show) {
show.setFinalized(false);
revisions.merge(show.getId(), 1L, Long::sum); revisions.merge(show.getId(), 1L, Long::sum);
} }
@@ -58,16 +59,12 @@ public class SchoolManagementService {
ManagementExceptionKind.INVALID_INPUT, ManagementExceptionKind.INVALID_INPUT,
"Scheduling requires at least one school group and one daypart."); "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); running.add(showId);
return new SchedulingInput( return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
showId,
revisions.getOrDefault(showId, 0L),
show.getDayParts().size(),
current.stream()
.map((final DanceGroup g) -> new GroupRequirement(
g.getId(), show.getAppearances().get(g.getType())))
.toList());
} }
/** Validates the complete result and input revision before replacing any saved assignments. */ /** Validates the complete result and input revision before replacing any saved assignments. */
@@ -97,6 +94,7 @@ public class SchoolManagementService {
.build()); .build());
} }
show.setDayParts(List.copyOf(replacement)); show.setDayParts(List.copyOf(replacement));
touch(show);
} }
private Map<Long, DanceGroup> validateAssignments( private Map<Long, DanceGroup> validateAssignments(
@@ -111,29 +109,13 @@ public class SchoolManagementService {
} }
final Map<Long, DanceGroup> current = new HashMap<>(); final Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g)); groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
validateParts(result.assignments(), current.keySet()); final List<String> violations = ScheduleRules.violations(input, result.assignments());
for (final GroupRequirement group : input.groups()) { if (!violations.isEmpty()) {
final long id = group.groupId(); throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
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.");
}
} }
return current; 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. */ /** Releases the run slot after success or failure, without changing saved assignments. */
public synchronized void endScheduling(final long showId) { public synchronized void endScheduling(final long showId) {
running.remove(showId); running.remove(showId);
@@ -186,6 +168,7 @@ public class SchoolManagementService {
final Dancer dancer = dancer(schoolId, dancerId); final Dancer dancer = dancer(schoolId, dancerId);
dancer.setName(name); dancer.setName(name);
dancer.setBirthDate(birthDate); dancer.setBirthDate(birthDate);
school(schoolId).getShows().forEach(this::touch);
return dancer; return dancer;
} }
@@ -193,6 +176,10 @@ public class SchoolManagementService {
final DanceSchool school = school(schoolId); final DanceSchool school = school(schoolId);
final Dancer dancer = dancer(schoolId, dancerId); final Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer)); 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); school.removeDancer(dancer);
dancers.delete(dancerId); dancers.delete(dancerId);
} }
@@ -224,7 +211,7 @@ public class SchoolManagementService {
final DanceGroup group = group(schoolId, groupId); final DanceGroup group = group(schoolId, groupId);
validateAgeRange(details.startAge(), details.endAge()); validateAgeRange(details.startAge(), details.endAge());
group.setCategory(details.category()); 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); school(schoolId).getShows().forEach(this::touch);
} }
group.setType(details.type()); group.setType(details.type());
@@ -248,11 +235,13 @@ public class SchoolManagementService {
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) { public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId); final DanceGroup group = group(schoolId, groupId);
group.addDancer(dancer(schoolId, dancerId)); group.addDancer(dancer(schoolId, dancerId));
school(schoolId).getShows().forEach(this::touch);
} }
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) { public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId); final DanceGroup group = group(schoolId, groupId);
group.removeDancer(dancer(schoolId, dancerId)); group.removeDancer(dancer(schoolId, dancerId));
school(schoolId).getShows().forEach(this::touch);
} }
public synchronized List<Show> shows(final long schoolId) { public synchronized List<Show> shows(final long schoolId) {
@@ -273,6 +262,7 @@ public class SchoolManagementService {
final Show show = shows.add(Show.builder() final Show show = shows.add(Show.builder()
.name(name) .name(name)
.dayParts(List.copyOf(parts)) .dayParts(List.copyOf(parts))
.preferredDayParts((parts.size() == 1 ? 1 : 2))
.appearances(Map.copyOf(counts)) .appearances(Map.copyOf(counts))
.build()); .build());
school.addShow(show); school.addShow(show);
@@ -292,18 +282,7 @@ public class SchoolManagementService {
ShowConfiguration.validate(parts, counts); ShowConfiguration.validate(parts, counts);
final boolean changed = final boolean changed =
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts())); !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
final List<DayPart> retained = parts.stream() final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
.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();
show.setName(name); show.setName(name);
show.setDayParts(retained); show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts)); show.setAppearances(Map.copyOf(counts));
@@ -413,6 +392,7 @@ public class SchoolManagementService {
final Show rebuilt = Show.builder() final Show rebuilt = Show.builder()
.name(show.getName()) .name(show.getName())
.appearances(show.getAppearances()) .appearances(show.getAppearances())
.preferredDayParts(show.getPreferredDayParts())
.dayParts(show.getDayParts().stream() .dayParts(show.getDayParts().stream()
.map((final DayPart p) -> DayPart.builder() .map((final DayPart p) -> DayPart.builder()
.date(p.getDate()) .date(p.getDate())

View File

@@ -5,11 +5,27 @@ import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class ShowConfiguration { public final class ShowConfiguration {
private 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) { public static Map<DanceGroupType, Integer> defaults(final int n) {
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class); final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) { if (n > 0) {
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
type, type,
switch (type) { switch (type) {
case PRE_SCHOOLER -> 1; case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n; case SHOW_CASE -> n;
default -> Math.min(2, 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 if (parts == null
|| parts.size() > 6 || parts.size() > 6
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null) || parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)

View File

@@ -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());
}
}
}

View File

@@ -7,7 +7,21 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; 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 { public Performance {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));

View File

@@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind; import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ShowConfiguration; 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( public record SchoolSnapshot(
Integer formatVersion, Integer formatVersion,
UUID key, UUID key,
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
final Map<DanceGroup, String> groups = ids(s.getDanceGroups()); final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
final Map<Show, String> shows = ids(s.getShows()); final Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot( return new SchoolSnapshot(
2, 3,
s.getKey(), s.getKey(),
s.getName(), s.getName(),
s.getDancers().stream() s.getDancers().stream()
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate())) .map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
.toList(), .toList(),
s.getCoaches().stream() 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(), .toList(),
s.getDanceGroups().stream() s.getDanceGroups().stream()
.map((final DanceGroup g) -> new Group( .map((final DanceGroup g) -> new Group(
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
.map(groups::get) .map(groups::get)
.toList())) .toList()))
.toList(), .toList(),
show.getAppearances())) show.getAppearances(),
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
show.isFinalized()))
.toList()); .toList());
} }
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */ /** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
public DanceSchool restore() { 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"); check(key != null, "key ontbreekt");
text(name, "schoolnaam"); text(name, "schoolnaam");
final DanceSchool school = DanceSchool.builder().key(key).name(name).build(); final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
final Map<String, Dancer> dancerMap = restoreDancers(school); 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); final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
restoreShows(school, groupMap); restoreShows(school, groupMap);
return school; return school;
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
return dancerMap; 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<>(); final Map<String, Coach> coachMap = new LinkedHashMap<>();
for (final Trainer c : list(coaches, "coaches")) { for (final Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); check(c != null, "Lege coach");
text(c.name(), "coachnaam"); 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()); coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach); put(coachMap, c.id(), coach);
school.addCoach(coach); school.addCoach(coach);
@@ -190,17 +198,25 @@ public record SchoolSnapshot(
parts.add(DayPart.builder() parts.add(DayPart.builder()
.date(part.date()) .date(part.date())
.type(part.type()) .type(part.type())
.danceGroups(unique(part.groupIds(), "groupIds").stream() .danceGroups(list(part.groupIds(), "groupIds").stream()
.map((final String id) -> .map((final String id) ->
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")) ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
.toList()) .toList())
.build()); .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() final Show entity = Show.builder()
.name(show.name()) .name(show.name())
.dayParts(parts) .dayParts(parts)
.appearances(Map.copyOf(show.appearances())) .appearances(
show.appearances() == null
? ShowConfiguration.defaults(parts.size())
: Map.copyOf(show.appearances()))
.preferredDayParts(preferredDayParts)
.finalized(false)
.build(); .build();
entity.setId((long) showIds.size()); entity.setId((long) showIds.size());
school.addShow(entity); school.addShow(entity);

View File

@@ -1,3 +1,7 @@
package nl.agilecc.agiledancers.service.model; 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);
}
}

View File

@@ -1,14 +1,11 @@
package nl.agilecc.agiledancers.solver; package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader; import com.google.ortools.Loader;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel; import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.CpSolver; import com.google.ortools.sat.CpSolver;
import com.google.ortools.sat.CpSolverStatus; import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr; import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
@@ -18,50 +15,51 @@ import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton @Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine { 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 @Override
public SchedulingResult solve(final SchedulingInput input) { public SchedulingResult solve(final SchedulingInput input) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries(); Loader.loadNativeLibraries();
final CpModel model = new CpModel(); final CpModel model = new CpModel();
final BoolVar[][] assigned = assignments(model, input); final OrderedAssignments vars = OrderedAssignments.create(model, input);
balance(model, assigned, input.dayParts()); final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
final CpSolver solver = new CpSolver(); final CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler.
solver.getParameters().setCatchSigintSignal(false); solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); 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); final CpSolverStatus status = solver.solve(model);
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(status); 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;
} }
model.addEquality(objective, Math.round(solver.objectiveValue()));
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(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances()); return best;
}
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}));
} }
private static void requireSolution(final CpSolverStatus status) { 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);
}
} }

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)));
}
}

View File

@@ -41,7 +41,12 @@ class SchedulingTest {
} }
private DanceGroup group(final DanceGroupType type) { 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) { private void run(final Show show) {
@@ -86,7 +91,7 @@ class SchedulingTest {
final Show show = service.createShow(school, "Show", List.of(), null); final Show show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty()); assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null); service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER)); assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
final var counts = new EnumMap<>(show.getAppearances()); final var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2); counts.put(DanceGroupType.PRE_SCHOOLER, 2);

View File

@@ -171,15 +171,17 @@ class SchoolManagementResourceTest {
final String show = school + "/shows/" + create(school + "/shows", SHOW); final String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400); given().post(show + "/schedule").then().statusCode(400);
final long group = create(school + "/dance-groups", GROUP); 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().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
final String before = given().get(show).asString(); final String before = given().get(show).asString();
final String counts = for (final String count : new String[] {"0", "2", "1.5", "7"}) {
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
final String body = final String body =
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
+ counts.formatted(count) + "}"; + count + "}";
put(show, body).statusCode(400); put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before)); given().get(show).then().body(equalTo(before));
} }

View File

@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
.then() .then()
.statusCode(200) .statusCode(200)
.header("Content-Disposition", containsString(".json")) .header("Content-Disposition", containsString(".json"))
.body("formatVersion", equalTo(2)) .body("formatVersion", equalTo(3))
.extract() .extract()
.asString(); .asString();
try { try {
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
for (final String bad : new String[] { for (final String bad : new String[] {
"{", "{",
"null", "null",
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"), json.replace("\"name\" :", "\"unknown\" :"),
json + " {}", json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null") json.replace("\"dancers\" : [ ]", "\"dancers\" : null")

View File

@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
@Test @Test
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() { public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1)); 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(); counts.clear();
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR)); assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
assertThrows( assertThrows(

View File

@@ -29,7 +29,7 @@ class SchedulingServiceTest {
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
final var schools = new SchoolManagementService(); final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId(); 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( final Show show = schools.createShow(
school, school,
"Show", "Show",
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
final var schools = new SchoolManagementService(); final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId(); 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( final Show show = schools.createShow(
school, school,
"Show", "Show",
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
ManagementExceptionKind.CONFLICT, ManagementExceptionKind.CONFLICT,
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())) assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
.getKind()); .getKind());
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of())); populatedGroup(schools, school);
} finally { } finally {
release.countDown(); release.countDown();
} }
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size()); 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) { private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
final SchedulingEngine engine = (final SchedulingInput input) -> { final SchedulingEngine engine = (final SchedulingInput input) -> {
entered.countDown(); entered.countDown();

View File

@@ -31,6 +31,7 @@ async function confirmDelete(page: Page) {
test('manages a school, shared memberships and shows through the real REST API', async ({ test('manages a school, shared memberships and shows through the real REST API', async ({
page, page,
request,
}) => { }) => {
const schoolName = `E2E Studio ${Date.now()}`; const schoolName = `E2E Studio ${Date.now()}`;
const browserErrors: string[] = []; const browserErrors: string[] = [];
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
).toBeVisible(); ).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true }); 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 tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); 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'); await expect(page.locator('.date-tile')).toContainText('jul 2027');
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: /Schedule groups/ }).click(); await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible(); await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).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: 'Zomershow bewerken' }).click();
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click(); await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02'); 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 tab(page, 'Dansers');
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click(); await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
await confirmDelete(page); 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 tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2); await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click(); 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([]); 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 }) => { test('shows a useful connection error and recovers on retry', async ({ page }) => {
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed')); await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
await page.goto('/schools'); await page.goto('/schools');

View File

@@ -27,11 +27,19 @@ export interface DayPart {
type: DayPartType; type: DayPartType;
danceGroupIds: number[]; danceGroupIds: number[];
} }
export interface Coach {
id: number;
name: string;
dancerId: number | null;
}
export interface Show { export interface Show {
id: number; id: number;
name: string; name: string;
dayParts: DayPart[]; dayParts: DayPart[];
appearances: Partial<Record<GroupType, number>>; appearances: Partial<Record<GroupType, number>>;
preferredDayParts: number;
finalized: boolean;
violations: string[];
} }
export interface SchoolRequest { export interface SchoolRequest {
name: string; name: string;
@@ -51,6 +59,7 @@ export interface ShowRequest {
name: string; name: string;
dayParts: { date: string; type: DayPartType }[]; dayParts: { date: string; type: DayPartType }[];
appearances?: Partial<Record<GroupType, number>>; appearances?: Partial<Record<GroupType, number>>;
preferredDayParts?: number;
} }
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show'; export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
export type EditorTarget = export type EditorTarget =
@@ -63,6 +72,7 @@ export interface SchoolData {
dancers: Dancer[]; dancers: Dancer[];
groups: DanceGroup[]; groups: DanceGroup[];
shows: Show[]; shows: Show[];
coaches: Coach[];
} }
export const GROUP_TYPES: { value: GroupType; label: string }[] = [ export const GROUP_TYPES: { value: GroupType; label: string }[] = [
{ value: 'REGULAR', label: 'Regulier' }, { value: 'REGULAR', label: 'Regulier' },

View File

@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
http.expectOne('/api/dance-schools/7/dance-groups').flush([]); http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
expect(received).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
http.expectOne('/api/dance-schools/7/shows').flush([]); 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', () => { it('updates an existing entity even when its id is zero', () => {

View File

@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable } from 'rxjs';
import { import {
Coach,
DanceGroup, DanceGroup,
Dancer, Dancer,
DancerRequest, DancerRequest,
@@ -29,6 +30,7 @@ export class SchoolApi {
dancers: this.http.get<Dancer[]>(`${base}/dancers`), dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`), groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`), shows: this.http.get<Show[]>(`${base}/shows`),
coaches: this.http.get<Coach[]>(`${base}/coaches`),
}); });
} }
saveSchool(body: SchoolRequest, id?: number): Observable<School> { saveSchool(body: SchoolRequest, id?: number): Observable<School> {
@@ -52,6 +54,20 @@ export class SchoolApi {
{ timeout: 1860000 }, { 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> { delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind kind

View File

@@ -151,6 +151,7 @@
</section> </section>
} }
@case ('dancers') { @case ('dancers') {
<app-coach-editor [data]="context" (changed)="reload()" />
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>Dansers</h2> <h2>Dansers</h2>
@@ -350,6 +351,16 @@
</div> </div>
<div class="show-details"> <div class="show-details">
<h3>{{ show.name }}</h3> <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> <p>{{ dateRange(show) }}</p>
@for (part of show.dayParts; track $index) { @for (part of show.dayParts; track $index) {
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p> <p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
@@ -357,7 +368,7 @@
<p role="status"> <p role="status">
{{ {{
scheduleValid(show) scheduleValid(show)
? 'Planning voldoet aan alle optredensaantallen.' ? 'Planning voldoet aan alle harde regels.'
: 'Geen geldige planning. Plan de groepen opnieuw in.' : 'Geen geldige planning. Plan de groepen opnieuw in.'
}} }}
</p> </p>
@@ -365,7 +376,10 @@
type="button" type="button"
class="button primary" class="button primary"
[disabled]=" [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)" (click)="schedule(show)"
> >
@@ -426,6 +440,15 @@
(saved)="saved()" (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) { @if (members(); as group) {
<app-group-members <app-group-members
[group]="group" [group]="group"

View File

@@ -17,6 +17,8 @@ import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor'; import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog'; import { ConfirmDialog } from '../shared/confirm-dialog';
import { ScheduleEditor } from '../shared/schedule-editor';
import { CoachEditor } from '../shared/coach-editor';
import { GroupMembers } from '../shared/group-members'; import { GroupMembers } from '../shared/group-members';
interface DeleteTarget { interface DeleteTarget {
@@ -29,6 +31,8 @@ interface DeleteTarget {
@Component({ @Component({
selector: 'app-school-page', selector: 'app-school-page',
imports: [ imports: [
ScheduleEditor,
CoachEditor,
SchoolTransfer, SchoolTransfer,
DatePipe, DatePipe,
RouterLink, RouterLink,
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
readonly error = signal(''); readonly error = signal('');
readonly notice = signal(''); readonly notice = signal('');
readonly editor = signal<EditorTarget | null>(null); readonly editor = signal<EditorTarget | null>(null);
readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null); readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null); readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]); readonly scheduling = signal<number[]>([]);
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
this.search.set(''); this.search.set('');
this.editor.set(null); this.editor.set(null);
this.members.set(null); this.members.set(null);
this.scheduleEditing.set(null);
this.deleting.set(null); this.deleting.set(null);
this.section.set(params.get('section') ?? 'overview'); this.section.set(params.get('section') ?? 'overview');
return this.api.schoolData(Number(params.get('schoolId'))).pipe( 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}`; return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
} }
scheduleValid(show: Show): boolean { scheduleValid(show: Show): boolean {
const groups = this.data()?.groups ?? []; return show.violations?.length === 0;
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)),
)
);
} }
groupNamesFor(ids: number[]): string { groupNamesFor(ids: number[]): string {
return ( return (
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') || ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
try { try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set( 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(); await this.reload();
} catch (error: unknown) { } catch (error: unknown) {
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
this.deleting.set(target); this.deleting.set(target);
} }
membershipChanged(ids: number[]): void { membershipChanged(ids: number[]): void {
void this.reload();
const groupId = this.members()?.id; const groupId = this.members()?.id;
this.data.update((data) => this.data.update((data) =>
data data

View 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);
}
}
}

View File

@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
const show: Show = { const show: Show = {
id: 3, id: 3,
name: 'Summer', name: 'Summer',
preferredDayParts: 2,
finalized: false,
violations: [],
dayParts: [ dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] }, { date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', 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 }, 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' }); const component = editor({ kind: 'show' });
component.name = 'Show'; component.name = 'Show';
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
@@ -87,31 +90,45 @@ describe('EntityEditor', () => {
component.dayParts[1].date = '2027-06-02'; component.dayParts[1].date = '2027-06-02';
component.addPart(); component.addPart();
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
expect(component.appearances).toEqual({ expect(component.preferredDayParts).toBe(2);
REGULAR: 2,
SELECTION: 2,
PRE_SCHOOLER: 1,
SHOW_CASE: 3,
PRE_EDUCATION: 3,
});
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
component.dayParts[2].date = '2027-06-02'; component.dayParts[2].date = '2027-06-02';
expect(component.validDetails()).toBe(false); expect(component.validDetails()).toBe(false);
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
component.appearances.PRE_SCHOOLER = 1.5; component.preferredDayParts = 1.5;
expect(component.validDetails()).toBe(false); 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 }); const component = editor({ kind: 'show', value: show });
component.addPart(); component.addPart();
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2); expect(component.preferredDayParts).toBe(2);
component.removePart(2); component.removePart(2);
component.removePart(1); component.removePart(1);
expect(component.appearances.REGULAR).toBe(2); expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(false); expect(component.validDetails()).toBe(true);
component.setSingleCounts(); component.preferredDayParts = 1;
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
}); });
@@ -120,11 +137,11 @@ describe('EntityEditor', () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try { try {
const component = editor({ kind: 'show', value: show }); const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1; component.preferredDayParts = 1;
await component.save(); await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled(); expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2; component.preferredDayParts = 2;
confirm.mockClear(); confirm.mockClear();
component.name = 'Renamed'; component.name = 'Renamed';
await component.save(); 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. // jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
@@ -163,8 +180,8 @@ describe('EntityEditor', () => {
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]')); const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5); expect(inputs).toHaveLength(1);
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true); expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
} finally { } finally {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor); if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal'); else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');

View File

@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
</button> </button>
</fieldset> </fieldset>
@if (dayParts.length) { @if (dayParts.length) {
<fieldset>
<legend>Optredens per groepstype</legend>
@for (option of types; track option.value) {
<label <label
>{{ option.label >Gewenst aantal dagdelen (x)
}}<input <input
type="number" type="number"
[name]="'count' + option.value" name="preferredDayParts"
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]" [(ngModel)]="preferredDayParts"
(ngModelChange)="appearances[option.value] = $event"
required
min="1" min="1"
[max]="dayParts.length" max="6"
step="1" step="1"
[disabled]="dayParts.length === 1" required
/></label> />
} </label>
<p class="form-hint"> <p class="form-hint">
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
vóór opslaan. dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
</p> </p>
@if (dayParts.length === 1 && !validCounts()) {
<button type="button" class="button secondary" (click)="setSingleCounts()">
Alle aantallen op één zetten
</button>
}
</fieldset>
} @else { } @else {
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p> <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'); readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi); private readonly api = inject(SchoolApi);
dayParts: DayPart[] = []; dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {}; private preferredDayPartsValue = 2;
private countsInitialized = false; private preferredDayPartsEdited = false;
get preferredDayParts(): number {
return this.preferredDayPartsValue;
}
set preferredDayParts(value: number) {
this.preferredDayPartsValue = value;
this.preferredDayPartsEdited = true;
}
name = ''; name = '';
date = ''; date = '';
type: GroupType = 'REGULAR'; type: GroupType = 'REGULAR';
@@ -223,44 +220,24 @@ export class EntityEditor implements OnInit {
if (target.kind === 'dancer') this.date = target.value.birthDate; if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') { if (target.kind === 'show') {
this.dayParts = target.value.dayParts.map((p) => ({ ...p })); this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
this.appearances = { ...target.value.appearances }; this.preferredDayParts = target.value.preferredDayParts;
this.countsInitialized = Object.keys(this.appearances).length > 0;
} }
} }
} }
addPart(): void { addPart(): void {
if (this.dayParts.length >= 6) return; if (this.dayParts.length >= 6) return;
const previous = this.dayParts.length;
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] }); this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
if (!this.countsInitialized) { if (!this.target().value && !this.preferredDayPartsEdited)
for (const { value } of this.types) { this.preferredDayPartsValue = Math.min(2, this.dayParts.length);
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);
}
}
} }
removePart(index: number): void { removePart(index: number): void {
this.dayParts.splice(index, 1); 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 { validCounts(): boolean {
return this.types.every( return (
(o) => Number.isInteger(this.preferredDayParts) &&
Number.isInteger(this.appearances[o.value]) && this.preferredDayParts >= 1 &&
this.appearances[o.value]! >= 1 && this.preferredDayParts <= 6
this.appearances[o.value]! <= this.dayParts.length,
); );
} }
toggleStyle(style: GroupStyle, checked: boolean): void { toggleStyle(style: GroupStyle, checked: boolean): void {
@@ -300,7 +277,7 @@ export class EntityEditor implements OnInit {
current.value && current.value &&
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !== (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(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]; affected = [current.value];
const names = affected const names = affected
@@ -349,7 +326,7 @@ export class EntityEditor implements OnInit {
{ {
name: this.name.trim(), name: this.name.trim(),
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })), dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
appearances: this.appearances, preferredDayParts: this.preferredDayParts,
}, },
id, id,
); );

View 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');
});
});

View 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);
}
}
}

View File

@@ -37,6 +37,9 @@ public class Ci {
case "install-runner" -> case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only")); Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run(); case "test" -> CiTests.run();
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()); default -> throw new IllegalArgumentException(usage());
} }
} catch (Commands.Failure e) { } catch (Commands.Failure e) {
@@ -54,6 +57,6 @@ public class Ci {
static String usage() { static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker" 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";
} }
} }

View File

@@ -101,6 +101,7 @@ class Pipeline {
run("node", "--version"); run("node", "--version");
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"); run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run(); CiTests.run();
ReleaseTests.run();
Path frontend = root.resolve("agiledancers-fe"); Path frontend = root.resolve("agiledancers-fe");
verifyFrontend(); verifyFrontend();
String platform = env.get("TARGET_PLATFORM"); String platform = env.get("TARGET_PLATFORM");

234
ci/Release.java Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.'

View 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
View 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
View 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

View File

@@ -2,7 +2,9 @@
De workflow staat in `.gitea/workflows/build.yaml`. Elke branchpush bouwt en test 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. 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 Tagpushes en aparte pull-requestevents starten geen extra workflow. Een auteur
kan een branchbuild bewust overslaan met een door Gitea ondersteunde markering, kan een branchbuild bewust overslaan met een door Gitea ondersteunde markering,
zoals `[skip ci]`; dit is expliciet toegestaan. zoals `[skip ci]`; dit is expliciet toegestaan.

View 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
View 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
View 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
View 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.

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# School JSON format, version 1 # School JSON format, version 3
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON. Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
@@ -11,19 +11,19 @@ There is no merge endpoint.
```json ```json
{ {
"formatVersion": 1, "formatVersion": 3,
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee", "key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
"name": "Studio", "name": "Studio",
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }], "dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
"coaches": [{ "id": "c1", "name": "Alex" }], "coaches": [{ "id": "c1", "name": "Alex", "dancerId": "d1" }],
"groups": [{ "groups": [{
"id": "g1", "category": "Hiphop", "type": "REGULAR", "id": "g1", "category": "Hiphop", "type": "REGULAR",
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"], "startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
"dancerIds": ["d1"], "coachId": "c1" "dancerIds": ["d1"], "coachId": "c1"
}], }],
"shows": [{ "shows": [{
"id": "s1", "name": "Summer show", "date": "2027-06-12", "id": "s1", "name": "Summer show", "preferredDayParts": 1, "finalized": false,
"dayParts": [{ "type": "MORNING", "groupIds": ["g1"] }] "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 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 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 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`; 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 birth dates cannot be in the future. Ages are nonnegative integers with
`startAge <= endAge`. Enum values use the same uppercase names as the management `startAge <= endAge`. Enum values use the same uppercase names as the management
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`. 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. values and unsupported versions are rejected. Validation is repeated on commit.
The entire school is prepared before publishing it under the shared service lock. The entire school is prepared before publishing it under the shared service lock.
Replacing retains the numeric school ID for existing browser links; imported Replacing retains the numeric school ID for existing browser links; imported

View 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.

View File

@@ -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.

View 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.