Skip to content

Commit b4780da

Browse files
Merge branch 'main' into fix/pr-deploy-by-sha
2 parents e654d7b + fb08573 commit b4780da

39 files changed

Lines changed: 686 additions & 177 deletions

backend/openshift.deploy.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ objects:
142142
value: "true"
143143
- name: ILCR_DATASOURCE_ENABLED
144144
value: ${ILCR_DATASOURCE_ENABLED}
145+
# Story 29.2 — point the Jasper report virtualizer's swap file at the dedicated
146+
# DISK-backed ephemeral volume below, NOT java.io.tmpdir (/tmp here is a Memory-backed
147+
# emptyDir, so spilling there would stay in RAM and defeat the heap relief).
148+
- name: ILCR_REPORTING_SWAP_DIR
149+
value: /var/tmp/report-swap
145150
- name: ORACLEDB_HOST
146151
valueFrom:
147152
secretKeyRef:
@@ -187,9 +192,13 @@ objects:
187192
requests:
188193
cpu: ${CPU_REQUEST}
189194
memory: ${MEMORY_REQUEST}
190-
ephemeral-storage: "200Mi"
195+
ephemeral-storage: "256Mi"
191196
limits:
192197
memory: ${MEMORY_LIMIT}
198+
# Bound total ephemeral (node) storage: base image scratch + the 512Mi report-swap
199+
# volume below (Story 29.2). A print that would exceed this is evicted — a bounded,
200+
# observable failure — rather than filling node disk.
201+
ephemeral-storage: "1Gi"
193202
securityContext:
194203
allowPrivilegeEscalation: false
195204
runAsNonRoot: true
@@ -201,6 +210,8 @@ objects:
201210
volumeMounts:
202211
- name: tmp
203212
mountPath: /tmp
213+
- name: report-swap
214+
mountPath: /var/tmp/report-swap
204215
- name: api-cert
205216
mountPath: /cert
206217
startupProbe:
@@ -226,6 +237,15 @@ objects:
226237
emptyDir:
227238
medium: Memory
228239
sizeLimit: 256Mi
240+
# Story 29.2 — dedicated DISK-backed ephemeral volume for the Jasper report swap file.
241+
# Deliberately NOT medium: Memory (unlike /tmp above): spilling large report page objects
242+
# here relieves the JVM heap onto node ephemeral storage instead of RAM, which is the whole
243+
# point of the virtualizer. Bounded by sizeLimit so a runaway print is evicted (a bounded,
244+
# observable failure) rather than silently consuming node disk. Writable despite the
245+
# read-only root filesystem because it is a mounted volume.
246+
- name: report-swap
247+
emptyDir:
248+
sizeLimit: 512Mi
229249
# Oracle keystore written by the init container, read by the app
230250
- name: api-cert
231251
persistentVolumeClaim:

backend/src/main/java/ca/bc/gov/nrs/ilcr/codetable/api/CodeTableApi.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableEntry;
44
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableSaveResponse;
55
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableSummary;
6+
import jakarta.validation.Valid;
67
import java.util.List;
78
import org.springframework.http.ResponseEntity;
89
import org.springframework.security.core.Authentication;
@@ -55,6 +56,6 @@ ResponseEntity<List<CodeTableEntry>> getEntries(
5556
@PutMapping("/{tableKey}/entries")
5657
ResponseEntity<CodeTableSaveResponse> saveEntry(
5758
@PathVariable String tableKey,
58-
@RequestBody CodeTableEntry entry,
59+
@Valid @RequestBody CodeTableEntry entry,
5960
Authentication authentication);
6061
}
Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
11
package ca.bc.gov.nrs.ilcr.codetable.dto;
22

3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Size;
36
import java.time.LocalDate;
47

58
/**
69
* One row of a lookup code table (Story 24.3 / UC-CODE-001, BR-02): a code, its description, and the
710
* effective/expiry window that gates whether downstream schedules offer it for a given year (BR-07).
811
*
9-
* @param code the code value (the table's primary key); {@code null}/blank for a Contractual add
10-
* @param description the human-readable label
11-
* @param effectiveDate first day the code is offered (inclusive); {@code null} = no lower bound
12+
* <p>Declarative constraints (Story 29.13) give the {@code saveEntry} write path a uniform 400
13+
* {@code ProblemDetail} for shape violations via {@code @Valid} — the same error shape as the rest of
14+
* the API — WITHOUT replacing the authoritative service-layer checks. {@code CodeTableService.validate}
15+
* still owns the required/length/date-order rules and their verbatim legacy message keys (FLD-001..005,
16+
* BR-06); these annotations are a declarative front line, not a replacement.
17+
*
18+
* <p>The {@code @Size} caps are deliberately OUTER BOUNDS — the maximum across every table
19+
* ({@code CodeTableRegistry}: code ≤ 20, description ≤ 500) — because a record-level annotation carries
20+
* one number but the real caps are PER-TABLE ({@code table.codeMaxLength()} /
21+
* {@code descriptionMaxLength()}). Keeping the annotation at the outer bound means the exact per-table
22+
* length rejection still comes from {@code validate()} with its verbatim message; the annotation only
23+
* catches absurd input early. Do NOT tighten these to a single table's cap.
24+
*
25+
* @param code the code value (the table's primary key); required + within the table's code cap on save
26+
* @param description the human-readable label; required + within the table's description cap on save
27+
* @param effectiveDate first day the code is offered (inclusive); required on save
1228
* @param expiryDate last day the code is offered (inclusive); {@code null} = never expires
1329
*/
1430
public record CodeTableEntry(
15-
String code, String description, LocalDate effectiveDate, LocalDate expiryDate) {
31+
@NotBlank(message = "{codeRequiredErrorMsg}") @Size(max = 20) String code,
32+
@NotBlank(message = "{descriptionRequiredErrorMsg}") @Size(max = 500) String description,
33+
@NotNull(message = "{effectiveDateRequiredErrorMsg}") LocalDate effectiveDate,
34+
LocalDate expiryDate) {
1635
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/SecurityConfiguration.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ public SecurityFilterChain securityFilterChain(
3636
) throws Exception {
3737
http
3838
.csrf(AbstractHttpConfigurer::disable)
39+
// No .cors(...) is configured, and that omission is a DELIBERATE, documented decision
40+
// (Story 29.14) — not an oversight. The browser talks to ONE origin: the Caddy edge
41+
// (frontend/Caddyfile) serves the SPA and reverse-proxies /api* to this backend, so the
42+
// SPA calls the API same-origin and no cross-origin browser request ever reaches it.
43+
// Combined with the stateless bearer-JWT model — no ambient cookie the browser would
44+
// attach cross-site, the same reasoning as the CSRF note above — the SAFE default for
45+
// this threat model is NO CORS. There is deliberately no allowedOrigins("*")/
46+
// allowCredentials surface here to get wrong. If the SPA is ever served from a DIFFERENT
47+
// origin, terminate CORS at the gateway/Caddy route (preferred); only if the gateway
48+
// cannot own it, add a tightly-scoped CorsConfigurationSource (named origin(s), explicit
49+
// methods/headers, credentials only if actually needed) wired via http.cors(...) HERE —
50+
// never a permissive wildcard, and never "*" combined with credentials.
3951
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
4052
.exceptionHandling(exceptions -> exceptions
4153
.authenticationEntryPoint((request, response, exception) ->

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/PrintService.java

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.List;
1010
import java.util.function.Predicate;
1111
import net.sf.jasperreports.engine.JasperPrint;
12+
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
1213
import org.slf4j.Logger;
1314
import org.slf4j.LoggerFactory;
1415
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -37,26 +38,29 @@ public class PrintService {
3738

3839
private final ReportService reportService;
3940
private final MillContextService millContextService;
41+
private final ReportVirtualizerFactory virtualizerFactory;
4042

41-
public PrintService(ReportService reportService, MillContextService millContextService) {
43+
public PrintService(ReportService reportService, MillContextService millContextService,
44+
ReportVirtualizerFactory virtualizerFactory) {
4245
this.reportService = reportService;
4346
this.millContextService = millContextService;
47+
this.virtualizerFactory = virtualizerFactory;
4448
}
4549

4650
/**
4751
* Render the combined Print Schedules PDF for the validated context and selection.
4852
*
4953
* @param context the validated (millId, year) pair
5054
* @param request the print selection (schedule flags + "all" + print options)
51-
* @return the combined, bookmarked PDF bytes
55+
* @return the filled report, ready to stream to the response (the caller closes it after export)
5256
* @throws ScheduleNotFoundException 404 — no selected in-scope schedule has any data (ERR-005)
5357
*/
5458
// Deliberately NOT @Transactional: this is six independent read-only reads, each *Service.getScheduleN
5559
// managing its own transaction and the Schedule 9 fill borrowing (and releasing) its own connection. A
5660
// method-wide readOnly transaction would pin ONE pooled connection for the whole render while the
5761
// Schedule 9 fill grabs a SECOND — two connections per /print on a maximum-pool-size of 5, so a handful
5862
// of concurrent prints would exhaust the pool and block until the 30s timeout.
59-
public byte[] render(MillYearContext context, PrintRequest request) {
63+
public RenderedReport render(MillYearContext context, PrintRequest request) {
6064
PrintOptions options =
6165
new PrintOptions(request.printScheduleInformation(), request.printComments());
6266
Predicate<ScheduleKey> selected = selectionOf(request);
@@ -65,6 +69,7 @@ public byte[] render(MillYearContext context, PrintRequest request) {
6569
// The ONLY requested content is the deferred Mill Information report — no in-scope schedule
6670
// and no content option. That yields no PDF for a reason unrelated to missing schedule data,
6771
// so surface the honest "not yet available" rather than the misleading "Schedule not found.".
72+
// Thrown before a virtualizer is created, so nothing to clean up.
6873
log.info("Mill-information-report-only selection for mill {} year {} — report not yet available",
6974
context.millId(), context.year());
7075
throw new MillInformationReportUnavailableException();
@@ -74,33 +79,48 @@ public byte[] render(MillYearContext context, PrintRequest request) {
7479
// instead of re-querying it for each of the five bean sections.
7580
String millTitleBlock = millContextService.resolveMillTitleBlock(context.millId());
7681

77-
List<JasperPrint> sections = new ArrayList<>();
78-
for (ScheduleKey key : ScheduleKey.values()) {
79-
if (!selected.test(key)) {
80-
continue;
82+
// One virtualizer for the whole combined fill (Story 29.2): every section's page objects spill to
83+
// the SAME swap file, so an "all schedules" print never pins the full section graph on the heap.
84+
JRSwapFileVirtualizer virtualizer = virtualizerFactory.create();
85+
boolean ownershipTransferred = false;
86+
try {
87+
List<JasperPrint> sections = new ArrayList<>();
88+
for (ScheduleKey key : ScheduleKey.values()) {
89+
if (!selected.test(key)) {
90+
continue;
91+
}
92+
// BR-08: pass the section's bookmark title so its template renders the top-level PDF outline
93+
// anchor. Every rendered schedule gets exactly one bookmark, including a single-schedule print.
94+
JasperPrint print = reportService.fillSection(
95+
key, context.millId(), context.year(), options, millTitleBlock, key.bookmarkTitle(),
96+
virtualizer);
97+
if (print == null) {
98+
// BR-09 skip-empty: a selected schedule with no data contributes nothing and never aborts.
99+
log.info("Skipping {} for mill {} year {} — no data",
100+
key, context.millId(), context.year());
101+
continue;
102+
}
103+
sections.add(print);
81104
}
82-
// BR-08: pass the section's bookmark title so its template renders the top-level PDF outline
83-
// anchor. Every rendered schedule gets exactly one bookmark, including a single-schedule print.
84-
JasperPrint print = reportService.fillSection(
85-
key, context.millId(), context.year(), options, millTitleBlock, key.bookmarkTitle());
86-
if (print == null) {
87-
// BR-09 skip-empty: a selected schedule with no data contributes nothing and never aborts.
88-
log.info("Skipping {} for mill {} year {} — no data",
89-
key, context.millId(), context.year());
90-
continue;
91-
}
92-
sections.add(print);
93-
}
94105

95-
logUnimplementedSelections(request);
106+
logUnimplementedSelections(request);
96107

97-
if (sections.isEmpty()) {
98-
// All-empty (S11): no selected content produced any section → no PDF, 404 ERR-005.
99-
throw new ScheduleNotFoundException();
108+
if (sections.isEmpty()) {
109+
// All-empty (S11): no selected content produced any section → no PDF, 404 ERR-005.
110+
throw new ScheduleNotFoundException();
111+
}
112+
log.info("Combining {} section(s) into one PDF for mill {} year {}",
113+
sections.size(), context.millId(), context.year());
114+
RenderedReport report = new RenderedReport(sections, virtualizer);
115+
ownershipTransferred = true;
116+
return report;
117+
} finally {
118+
// All-empty (404) or a fill failure produces no PDF, so clean the swap file here; otherwise
119+
// ownership passes to the RenderedReport, which the streaming caller closes after export.
120+
if (!ownershipTransferred) {
121+
virtualizer.cleanup();
122+
}
100123
}
101-
log.info("Combining {} section(s) into one PDF for mill {} year {}",
102-
sections.size(), context.millId(), context.year());
103-
return reportService.exportPdf(sections);
104124
}
105125

106126
/**
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package ca.bc.gov.nrs.ilcr.reporting;
2+
3+
import java.io.OutputStream;
4+
import java.util.List;
5+
import net.sf.jasperreports.engine.JRException;
6+
import net.sf.jasperreports.engine.JasperPrint;
7+
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
8+
import net.sf.jasperreports.export.SimpleExporterInput;
9+
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput;
10+
import net.sf.jasperreports.pdf.JRPdfExporter;
11+
12+
/**
13+
* One filled report ready to export (Story 29.2): the filled sections plus the {@link
14+
* JRSwapFileVirtualizer} their fill spilled to. {@link #writeTo(OutputStream)} exports the sections
15+
* straight to a caller-supplied stream — the servlet output stream — so the PDF is never accumulated
16+
* as a {@code byte[]} on the heap; {@link #close()} disposes the virtualizer's swap file.
17+
*
18+
* <p>AutoCloseable so the streaming caller (the controller's {@code StreamingResponseBody}) cleans up
19+
* on BOTH success and failure: a render that throws mid-export must not leak a swap file. The fill
20+
* that produced these sections has already happened (and may have thrown the empty-schedule 404)
21+
* BEFORE this holder exists, so streaming and virtualization stay pure transport/memory concerns and
22+
* never move a business outcome after the response is committed.
23+
*/
24+
class RenderedReport implements AutoCloseable {
25+
26+
private final List<JasperPrint> sections;
27+
private final JRSwapFileVirtualizer virtualizer;
28+
29+
RenderedReport(List<JasperPrint> sections, JRSwapFileVirtualizer virtualizer) {
30+
this.sections = sections;
31+
this.virtualizer = virtualizer;
32+
}
33+
34+
/**
35+
* Export the filled sections to ONE PDF written directly to {@code out} (BR-08). Each section's
36+
* top-level bookmark is an in-template outline ANCHOR keyed to its {@code bookmarkTitle} fill
37+
* parameter, NOT JasperReports' batch-mode document bookmarks: the latter only emit a bookmark
38+
* when the export batch holds MORE THAN ONE JasperPrint (JRPdfExporter gates {@code
39+
* addBookmark(getName())} on {@code items.size() > 1}), so a single-schedule {@code /print} would
40+
* silently get an empty outline. The anchor renders one bookmark per section for a single-section
41+
* PDF just as for a combined one; the caller gates it by passing a null bookmark title (the
42+
* standalone Schedule 9 path) to suppress the anchor.
43+
*
44+
* <p>Streams rather than buffers: the exporter output wraps {@code out} directly, so a big "all
45+
* schedules" print never pins the whole PDF as a {@code byte[]} on the JVM heap. {@link
46+
* SimpleOutputStreamExporterOutput} does not own {@code out} (it did not open it), so it leaves the
47+
* servlet stream for the container to close.
48+
*/
49+
void writeTo(OutputStream out) {
50+
JRPdfExporter exporter = new JRPdfExporter();
51+
exporter.setExporterInput(SimpleExporterInput.getInstance(sections));
52+
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
53+
try {
54+
exporter.exportReport();
55+
} catch (JRException e) {
56+
throw new ReportGenerationException("Failed to export the combined report to PDF", e);
57+
}
58+
}
59+
60+
/**
61+
* Dispose the virtualizer, deleting its swap file. The streaming caller wraps this in
62+
* try-with-resources, so it runs on both the success and error paths and a swap file is never
63+
* leaked. The virtualizer owns its swap file (swapOwner=true in {@link ReportVirtualizerFactory}),
64+
* so {@code cleanup()} removes the on-disk file, not just the in-memory page cache.
65+
*/
66+
@Override
67+
public void close() {
68+
if (virtualizer != null) {
69+
virtualizer.cleanup();
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)