Skip to content

Commit df083d6

Browse files
KasinhouMatus Kasakclaude
authored
JCU/feat(health-report): port health-report and report-diff scripts (#1363)
* JCU/feat(health-report): port health-report and report-diff scripts Port the health-report and report-diff DSpace scripts from the LINDAT/UFAL dtq-dev branch, adapted to DSpace 9.3. Both run via CLI and the Processes UI. - Add ReportResult entity + service/DAO + Spring wiring (core-services.xml, core-dao-services.xml) + hibernate mapping + report_result migration (postgres + h2) - Refactor org.dspace.health checks to emit JSON output; add EmbargoInfoCheck and DateFormatConstants; align to 9.3 APIs (jakarta.*, Instant/LocalDate) - Register health-report and report-diff in scripts.xml (main + test override) - Add zjsonpatch dependency and report-diff-fields.json for report diffing - Drop CLARIN-only LicenseCheck (not present in vanilla DSpace 9.3) - Remove legacy org.dspace.health.Report CLI command (superseded by health-report) - Add HealthReportIT and ReportDiffIT Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * JCU/test: request all scripts in findAllScriptsTest Adding health-report and report-diff pushes the total script count past the default REST page size (20), so the default page no longer contains every script. Request all scripts explicitly (matching findAllScriptsSortedAlphabeticallyTest) so the containsInAnyOrder assertion sees the full list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * JCU/refactor(health-report): address review — close config stream, reset embargo check state - ReportDiff.loadFieldConfiguration(): use try-with-resources so the classpath InputStream is always closed. - EmbargoInfoCheck: clear its result lists at the start of each run(). Check plugins are held in a static map and reused for the JVM lifetime, so without this the lists would accumulate across runs and double-count on a long-running webapp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef3d04b commit df083d6

35 files changed

Lines changed: 4296 additions & 395 deletions

dspace-api/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,5 +960,12 @@
960960
<version>1.6.15</version>
961961
<scope>test</scope>
962962
</dependency>
963+
964+
<!-- Used by the report-diff script to compute JSON diffs between two health reports -->
965+
<dependency>
966+
<groupId>com.flipkart.zjsonpatch</groupId>
967+
<artifactId>zjsonpatch</artifactId>
968+
<version>0.4.16</version>
969+
</dependency>
963970
</dependencies>
964971
</project>
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.app.healthreport;
9+
10+
import static org.apache.commons.io.IOUtils.toInputStream;
11+
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
import java.nio.charset.StandardCharsets;
15+
import java.text.SimpleDateFormat;
16+
import java.util.ArrayList;
17+
import java.util.Date;
18+
import java.util.LinkedHashMap;
19+
import java.util.LinkedHashSet;
20+
import java.util.List;
21+
import java.util.Locale;
22+
import java.util.Map;
23+
import java.util.Set;
24+
25+
import jakarta.mail.MessagingException;
26+
import org.apache.commons.cli.Option;
27+
import org.apache.commons.cli.ParseException;
28+
import org.apache.logging.log4j.LogManager;
29+
import org.apache.logging.log4j.Logger;
30+
import org.dspace.content.ReportResult;
31+
import org.dspace.content.factory.ContentServiceFactory;
32+
import org.dspace.content.service.ReportResultService;
33+
import org.dspace.core.Context;
34+
import org.dspace.core.Email;
35+
import org.dspace.core.I18nUtil;
36+
import org.dspace.core.factory.CoreServiceFactory;
37+
import org.dspace.core.service.PluginService;
38+
import org.dspace.eperson.factory.EPersonServiceFactory;
39+
import org.dspace.eperson.service.EPersonService;
40+
import org.dspace.health.Check;
41+
import org.dspace.health.ReportInfo;
42+
import org.dspace.scripts.DSpaceRunnable;
43+
import org.dspace.services.ConfigurationService;
44+
import org.dspace.services.factory.DSpaceServicesFactory;
45+
import org.dspace.utils.DSpace;
46+
import org.json.JSONArray;
47+
import org.json.JSONObject;
48+
49+
/**
50+
* This class is used to generate a health report of the DSpace instance.
51+
* @author Matus Kasak (dspace at dataquest.sk)
52+
* @author Milan Majchrak (dspace at dataquest.sk)
53+
*/
54+
public class HealthReport extends DSpaceRunnable<HealthReportScriptConfiguration> {
55+
private static final Logger log = LogManager.getLogger(HealthReport.class);
56+
57+
private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
58+
private ReportResultService reportResultService = ContentServiceFactory.getInstance().getReportResultService();
59+
private EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService();
60+
61+
/**
62+
* Checks to be performed.
63+
*/
64+
private static final LinkedHashMap<String, Check> checks = getChecks();
65+
66+
/**
67+
* `-h`: Help, show help information.
68+
*/
69+
private boolean help = false;
70+
71+
/**
72+
* `-e`: Email, send report to specified email address.
73+
*/
74+
private String[] emails;
75+
76+
/**
77+
* `-c`: Check, perform only specific checks by index (0-`getNumberOfChecks()`).
78+
* Supports multiple values.
79+
*/
80+
private List<Integer> specificChecks = new ArrayList<>();
81+
82+
/**
83+
* `-f`: For, specify the last N days to consider.
84+
* Default value is set in dspace.cfg.
85+
*/
86+
private int forLastNDays = configurationService.getIntProperty("healthcheck.last_n_days");
87+
88+
/**
89+
* `-r`: Report, specify a file to save the report.
90+
*/
91+
private String reportFile;
92+
93+
@Override
94+
public HealthReportScriptConfiguration getScriptConfiguration() {
95+
return new DSpace().getServiceManager()
96+
.getServiceByName("health-report", HealthReportScriptConfiguration.class);
97+
}
98+
99+
@Override
100+
public void setup() throws ParseException {
101+
// `-h`: Help, show help information.
102+
if (commandLine.hasOption('h')) {
103+
help = true;
104+
return;
105+
}
106+
107+
// `-e`: Email, send report to specified email address.
108+
if (commandLine.hasOption('e')) {
109+
emails = commandLine.getOptionValues('e');
110+
}
111+
112+
// `-c`: Check, perform only specific checks by index (0-`getNumberOfChecks()`).
113+
// Supports multiple values e.g. -c 0 -c 3 -c 4
114+
if (commandLine.hasOption('c')) {
115+
String[] checkOptions = commandLine.getOptionValues('c');
116+
for (String checkOption : checkOptions) {
117+
try {
118+
int checkIndex = Integer.parseInt(checkOption);
119+
if (checkIndex < 0 || checkIndex >= getNumberOfChecks()) {
120+
handler.logError("Invalid value for check: " + checkOption +
121+
". Must be an integer from 0 to " + (getNumberOfChecks() - 1) + ".");
122+
throw new ParseException("Invalid check index: " + checkOption);
123+
}
124+
specificChecks.add(checkIndex);
125+
} catch (NumberFormatException e) {
126+
handler.logError("Invalid value for check: '" + checkOption +
127+
"'. It has to be an integer number from 0 to " + (getNumberOfChecks() - 1) + ".");
128+
throw new ParseException("Invalid check value: " + checkOption);
129+
}
130+
}
131+
}
132+
133+
// `-f`: For, specify the last N days to consider. Must be a positive integer.
134+
if (commandLine.hasOption('f')) {
135+
String daysOption = commandLine.getOptionValue('f');
136+
try {
137+
forLastNDays = Integer.parseInt(daysOption);
138+
if (forLastNDays <= 0) {
139+
handler.logError("Invalid value for -f: " + daysOption +
140+
". Must be a positive integer (greater than 0).");
141+
throw new ParseException("Invalid -f value: " + daysOption);
142+
}
143+
} catch (NumberFormatException e) {
144+
handler.logError("Invalid value for -f: '" + daysOption +
145+
"'. Must be a positive integer.");
146+
throw new ParseException("Invalid -f value: " + daysOption);
147+
}
148+
}
149+
150+
// `-r`: Report, specify a file to save the report.
151+
if (commandLine.hasOption('r')) {
152+
reportFile = commandLine.getOptionValue('r');
153+
}
154+
}
155+
156+
@Override
157+
public void internalRun() throws Exception {
158+
// When a help option (-h) is passed, the framework prints help during initialize() and skips
159+
// setup()/parse(), leaving commandLine null (and help false). In that case there is nothing to run.
160+
if (commandLine == null) {
161+
return;
162+
}
163+
if (help) {
164+
printHelp();
165+
return;
166+
}
167+
168+
try (Context context = new Context()) {
169+
context.setCurrentUser(ePersonService.find(context, this.getEpersonIdentifier()));
170+
171+
ReportInfo ri = new ReportInfo(this.forLastNDays);
172+
173+
StringBuilder sbReport = new StringBuilder();
174+
175+
int position = -1;
176+
JSONObject root = new JSONObject();
177+
// Create the array
178+
JSONArray checksArray = new JSONArray();
179+
for (Map.Entry<String, Check> check_entry : checks.entrySet()) {
180+
++position;
181+
if (!specificChecks.isEmpty() && !specificChecks.contains(position)) {
182+
continue;
183+
}
184+
185+
String name = check_entry.getKey();
186+
Check check = check_entry.getValue();
187+
188+
log.info("#{}. Processing [{}] at [{}]", position, name, new SimpleDateFormat(
189+
"yyyy-MM-dd HH:mm:ss.SSS").format(new Date()));
190+
191+
sbReport.append("\n######################\n\n").append(name).append(":\n");
192+
check.report(ri);
193+
sbReport.append(check.getReport());
194+
195+
// JSON:
196+
// Check name: {Report}
197+
JSONObject report = check.getReportJson(); // assume check is already defined
198+
if (report == null) {
199+
report = new JSONObject(); // or handle appropriately
200+
log.warn("Check {} returned null JSON report", name);
201+
}
202+
JSONObject checkJson = new JSONObject();
203+
checkJson.put("name", name);
204+
checkJson.put("report", report);
205+
// Add items to array
206+
checksArray.put(checkJson);
207+
}
208+
209+
// Add array to root object under a key "checks"
210+
root.put("checks", checksArray);
211+
212+
// Add health report summary to the ReportResult object
213+
ReportResult reportResult = reportResultService.create(context);
214+
reportResult.setArgs(printCommandlineOptions());
215+
reportResult.setExecutor(context.getCurrentUser());
216+
reportResult.setType("healthcheck");
217+
reportResult.setValue(root.toString());
218+
reportResultService.update(context, reportResult);
219+
context.commit();
220+
221+
// Prepend the header with the persisted report ID so users can refer to it later
222+
String finalReport = "\n\nHEALTH REPORT " + reportResult.getID() + ":\n" + sbReport.toString();
223+
224+
// save output to file
225+
if (reportFile != null) {
226+
InputStream inputStream = toInputStream(finalReport, StandardCharsets.UTF_8);
227+
handler.writeFilestream(context, reportFile, inputStream, "export");
228+
context.commit();
229+
230+
context.restoreAuthSystemState();
231+
232+
}
233+
234+
// send email to email address from argument
235+
if (emails != null && emails.length > 0) {
236+
try {
237+
Email e = Email.getEmail(I18nUtil.getEmailFilename(Locale.getDefault(), "healthcheck"));
238+
for (String recipient : emails) {
239+
e.addRecipient(recipient);
240+
}
241+
e.addArgument(finalReport);
242+
e.send();
243+
handler.logInfo("Report sent to: " + String.join(", ", emails));
244+
} catch (IOException | MessagingException e) {
245+
log.error("Error sending email:", e);
246+
handler.logError("Error sending email to " + String.join(", ", emails)
247+
+ ": " + e.getMessage());
248+
}
249+
}
250+
251+
handler.logInfo(finalReport);
252+
}
253+
}
254+
255+
@Override
256+
public void printHelp() {
257+
int configuredForLastNDays = configurationService.getIntProperty("healthcheck.last_n_days");
258+
handler.logInfo("\n\nHELP\nThis process creates a health report of your DSpace.\n" +
259+
"You can choose from these available options:\n" +
260+
" -h, --help Show help information\n" +
261+
" -e, --email Send report to specified email address\n" +
262+
" -c, --check Perform specific check(s) by index (0-" + (getNumberOfChecks() - 1) +
263+
"). Repeat the flag (e.g. -c 1 -c 3) to run multiple checks. " +
264+
"Default: All checks\n" +
265+
" -f, --for Specify the last N days to consider (positive integer). " +
266+
"Default: " + configuredForLastNDays + "\n" +
267+
" -r, --report Specify a file to save the report\n\n" +
268+
"Available checks:\n" + checksNamesToString() + "\n"
269+
);
270+
}
271+
272+
/**
273+
* Print command line options in a readable format.
274+
* This method is used to print the options used for the report.
275+
*/
276+
private String printCommandlineOptions() {
277+
StringBuilder options = new StringBuilder();
278+
Set<String> processedOptions = new LinkedHashSet<>();
279+
280+
for (Option option : commandLine.getOptions()) {
281+
String key = option.getOpt();
282+
if (key == null || processedOptions.contains(key)) {
283+
continue;
284+
}
285+
processedOptions.add(key);
286+
287+
String[] values = commandLine.getOptionValues(key);
288+
if (values != null && values.length > 0) {
289+
for (String value : values) {
290+
options.append(String.format(" -%s: %s\n", key, value));
291+
}
292+
} else {
293+
options.append(String.format(" -%s\n", key));
294+
}
295+
}
296+
return options.toString();
297+
}
298+
299+
/**
300+
* Convert checks names to string.
301+
*/
302+
private String checksNamesToString() {
303+
StringBuilder names = new StringBuilder();
304+
int pos = 0;
305+
for (String name : checks.keySet()) {
306+
names.append(String.format(" %d. %s\n", pos++, name));
307+
}
308+
return names.toString();
309+
}
310+
311+
/**
312+
* Get the number of checks. This is used for the `-c` option.
313+
*/
314+
public static int getNumberOfChecks() {
315+
return checks.size();
316+
}
317+
318+
/**
319+
* Get the name of a specific check by its index.
320+
* This is used for the `-c` option.
321+
*
322+
* @param specificCheck the index of the check
323+
* @return the name of the check, or null if the index is invalid
324+
*/
325+
public static String getCheckName(int specificCheck) {
326+
if (specificCheck < 0 || specificCheck >= getNumberOfChecks()) {
327+
return null;
328+
}
329+
int pos = 0;
330+
for (String name : checks.keySet()) {
331+
if (pos == specificCheck) {
332+
return name;
333+
}
334+
pos++;
335+
}
336+
return null; // should not happen
337+
}
338+
339+
/**
340+
* Create check list from configured healthcheck plugins.
341+
*/
342+
private static LinkedHashMap<String, Check> getChecks() {
343+
LinkedHashMap<String, Check> loadedChecks = new LinkedHashMap<>();
344+
String[] checkNames = DSpaceServicesFactory.getInstance().getConfigurationService()
345+
.getArrayProperty("healthcheck.checks");
346+
PluginService pluginService = CoreServiceFactory.getInstance().getPluginService();
347+
348+
for (String checkName : checkNames) {
349+
Check check = (Check) pluginService.getNamedPlugin(Check.class, checkName);
350+
if (check != null) {
351+
loadedChecks.put(checkName, check);
352+
} else {
353+
log.warn("Could not find implementation for [{}]", checkName);
354+
}
355+
}
356+
357+
return loadedChecks;
358+
}
359+
}

0 commit comments

Comments
 (0)