View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.app.web.admin.maintenance;
17  
18  import java.io.IOException;
19  import java.net.InetAddress;
20  import java.nio.file.Files;
21  import java.nio.file.Path;
22  import java.nio.file.Paths;
23  import java.text.SimpleDateFormat;
24  import java.util.Arrays;
25  import java.util.Date;
26  import java.util.HashSet;
27  import java.util.Properties;
28  import java.util.Set;
29  import java.util.stream.Stream;
30  import java.util.zip.ZipEntry;
31  import java.util.zip.ZipOutputStream;
32  
33  import org.apache.commons.text.StringEscapeUtils;
34  import org.apache.logging.log4j.LogManager;
35  import org.apache.logging.log4j.Logger;
36  import org.codelibs.core.io.CopyUtil;
37  import org.codelibs.core.lang.StringUtil;
38  import org.codelibs.curl.CurlResponse;
39  import org.codelibs.fess.Constants;
40  import org.codelibs.fess.annotation.Secured;
41  import org.codelibs.fess.app.web.base.FessAdminAction;
42  import org.codelibs.fess.helper.CoordinatorHelper;
43  import org.codelibs.fess.mylasta.direction.FessConfig.SimpleImpl;
44  import org.codelibs.fess.opensearch.client.SearchEngineClient;
45  import org.codelibs.fess.util.ComponentUtil;
46  import org.codelibs.fess.util.IpAddressUtil;
47  import org.codelibs.fess.util.SearchEngineUtil;
48  import org.lastaflute.web.Execute;
49  import org.lastaflute.web.response.ActionResponse;
50  import org.lastaflute.web.response.HtmlResponse;
51  import org.lastaflute.web.ruts.process.ActionRuntime;
52  import org.opensearch.core.action.ActionListener;
53  
54  import jakarta.annotation.Resource;
55  
56  /**
57   * Admin action for maintenance operations including reindexing, log management,
58   * and system diagnostics.
59   */
60  public class AdminMaintenanceAction extends FessAdminAction {
61  
62      /**
63       * Default constructor for AdminMaintenanceAction.
64       */
65      public AdminMaintenanceAction() {
66          super();
67      }
68  
69      /**
70       * Role identifier for admin maintenance operations.
71       */
72      public static final String ROLE = "admin-maintenance";
73  
74      // ===================================================================================
75      //                                                                            Constant
76      //
77      private static final Logger logger = LogManager.getLogger(AdminMaintenanceAction.class);
78  
79      private static final String[] CAT_NAMES =
80              { "aliases", "allocation", "count", "fielddata", "health", "indices", "master", "nodeattrs", "nodes", "pending_tasks",
81                      "plugins", "recovery", "repositories", "thread_pool", "shards", "segments", "snapshots", "templates" };
82  
83      // ===================================================================================
84      //                                                                           Attribute
85      //
86  
87      /**
88       * Search engine client for performing maintenance operations on indices.
89       */
90      @Resource
91      protected SearchEngineClient searchEngineClient;
92  
93      // ===================================================================================
94      //                                                                               Hook
95      //                                                                              ======
96      @Override
97      protected void setupHtmlData(final ActionRuntime runtime) {
98          super.setupHtmlData(runtime);
99          runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameMaintenance()));
100     }
101 
102     @Override
103     protected String getActionRole() {
104         return ROLE;
105     }
106 
107     // ===================================================================================
108     //                                                                      Search Execute
109     //                                                                      ==============
110 
111     /**
112      * Displays the main maintenance page.
113      *
114      * @return HTML response for the maintenance index page
115      */
116     @Execute
117     @Secured({ ROLE, ROLE + VIEW })
118     public HtmlResponse index() {
119         saveToken();
120         return asIndexHtml();
121     }
122 
123     private HtmlResponse asIndexHtml() {
124         return asHtml(path_AdminMaintenance_AdminMaintenanceJsp).useForm(ActionForm.class, op -> op.setup(f -> {
125             f.replaceAliases = Constants.ON;
126             f.resetDictionaries = null;
127         }));
128     }
129 
130     /**
131      * Starts a reindex operation based on the provided form parameters.
132      *
133      * @param form the action form containing reindex configuration
134      * @return HTML response redirecting to the maintenance page
135      */
136     @Execute
137     @Secured({ ROLE })
138     public HtmlResponse reindexOnly(final ActionForm form) {
139         validate(form, messages -> {}, this::asIndexHtml);
140         verifyToken(this::asIndexHtml);
141         if (startReindex(isCheckboxEnabled(form.replaceAliases), isCheckboxEnabled(form.resetDictionaries), form.numberOfShardsForDoc,
142                 form.autoExpandReplicasForDoc)) {
143             saveInfo(messages -> messages.addSuccessStartedDataUpdate(GLOBAL));
144         }
145         return redirect(getClass());
146     }
147 
148     /**
149      * Reloads the document index by closing and reopening it.
150      *
151      * @param form the action form (validated but not used for configuration)
152      * @return HTML response redirecting to the maintenance page
153      */
154     @Execute
155     @Secured({ ROLE })
156     public HtmlResponse reloadDocIndex(final ActionForm form) {
157         validate(form, messages -> {}, this::asIndexHtml);
158         verifyToken(this::asIndexHtml);
159         final CoordinatorHelper coordinator = ComponentUtil.getCoordinatorHelper();
160         if (!coordinator.tryStartOperation("reload_doc_index")) {
161             saveError(messages -> messages.addErrorsOperationAlreadyRunning(GLOBAL,
162                     coordinator.getOperationInfo("reload_doc_index").map(o -> o.hostname).orElse("unknown")));
163             return redirect(getClass());
164         }
165         try {
166             searchEngineClient.flushConfigFiles(() -> {
167                 final String docIndex = fessConfig.getIndexDocumentUpdateIndex();
168                 searchEngineClient.admin().indices().prepareClose(docIndex).execute(ActionListener.wrap(res -> {
169                     logger.info("Closing index: {}", docIndex);
170                     searchEngineClient.admin().indices().prepareOpen(docIndex).execute(ActionListener.wrap(res2 -> {
171                         logger.info("Opened index: {}", docIndex);
172                         coordinator.completeOperation("reload_doc_index");
173                     }, e -> {
174                         logger.warn("Failed to open index: {}", docIndex, e);
175                         coordinator.completeOperation("reload_doc_index");
176                     }));
177                 }, e -> {
178                     logger.warn("Failed to close index: {}", docIndex, e);
179                     coordinator.completeOperation("reload_doc_index");
180                 }));
181             });
182         } catch (final Exception e) {
183             coordinator.completeOperation("reload_doc_index");
184             throw e;
185         }
186         saveInfo(messages -> messages.addSuccessStartedDataUpdate(GLOBAL));
187         return redirect(getClass());
188     }
189 
190     /**
191      * Clears all crawler indices including queue, data, and filter indices.
192      *
193      * @param form the action form (validated but not used for configuration)
194      * @return HTML response redirecting to the maintenance page
195      */
196     @Execute
197     @Secured({ ROLE })
198     public HtmlResponse clearCrawlerIndex(final ActionForm form) {
199         validate(form, messages -> {}, this::asIndexHtml);
200         verifyToken(this::asIndexHtml);
201         final CoordinatorHelper coordinator = ComponentUtil.getCoordinatorHelper();
202         if (!coordinator.tryStartOperation("clear_crawler_index")) {
203             saveError(messages -> messages.addErrorsOperationAlreadyRunning(GLOBAL,
204                     coordinator.getOperationInfo("clear_crawler_index").map(o -> o.hostname).orElse("unknown")));
205             return redirect(getClass());
206         }
207         try {
208             searchEngineClient.admin()
209                     .indices()
210                     .prepareDelete(//
211                             fessConfig.getIndexDocumentCrawlerIndex() + ".queue", //
212                             fessConfig.getIndexDocumentCrawlerIndex() + ".data", //
213                             fessConfig.getIndexDocumentCrawlerIndex() + ".filter")
214                     .execute(ActionListener.wrap(res -> {
215                         logger.info("Deleted .crawler indices.");
216                         coordinator.completeOperation("clear_crawler_index");
217                     }, e -> {
218                         logger.warn("Failed to delete .crawler.* indices.", e);
219                         coordinator.completeOperation("clear_crawler_index");
220                     }));
221         } catch (final Exception e) {
222             coordinator.completeOperation("clear_crawler_index");
223             throw e;
224         }
225         saveInfo(messages -> messages.addSuccessStartedDataUpdate(GLOBAL));
226         return redirect(getClass());
227     }
228 
229     /**
230      * Downloads diagnostic logs and system information as a ZIP file.
231      *
232      * @param form the action form (validated but not used for configuration)
233      * @return streaming response containing the diagnostic ZIP file
234      */
235     @Execute
236     @Secured({ ROLE, ROLE + VIEW })
237     public ActionResponse downloadLogs(final ActionForm form) {
238         validate(form, messages -> {}, this::asIndexHtml);
239         verifyTokenKeep(this::asIndexHtml);
240 
241         final String diagnosticId = "log" + new SimpleDateFormat("yyyyMMddHHmm").format(ComponentUtil.getSystemHelper().getCurrentTime());
242         return asStream(diagnosticId + ".zip").contentTypeOctetStream().stream(out -> {
243             try (ZipOutputStream zos = new ZipOutputStream(out.stream())) {
244                 writeLogFiles(zos, diagnosticId);
245                 writeSystemProperties(zos, diagnosticId);
246                 writeFessBasicConfig(zos, diagnosticId);
247                 writeFessConfig(zos, diagnosticId);
248                 writeFesenCat(zos, diagnosticId);
249                 writeFesenJson(zos, diagnosticId);
250             }
251         });
252     }
253 
254     /**
255      * Writes OpenSearch JSON API responses to the ZIP output stream.
256      *
257      * @param zos the ZIP output stream to write to
258      * @param id the diagnostic ID for organizing files in the ZIP
259      */
260     protected void writeFesenJson(final ZipOutputStream zos, final String id) {
261         writeElastisearchJsonApi(zos, id, "cluster", "health");
262         writeElastisearchJsonApi(zos, id, "cluster", "state");
263         writeElastisearchJsonApi(zos, id, "cluster", "stats");
264         writeElastisearchJsonApi(zos, id, "cluster", "pending_tasks");
265         writeElastisearchJsonApi(zos, id, "nodes", "stats");
266         writeElastisearchJsonApi(zos, id, "nodes", "_all");
267         writeElastisearchJsonApi(zos, id, "nodes", "usage");
268         writeElastisearchJsonApi(zos, id, "remote", "info");
269         writeElastisearchJsonApi(zos, id, "tasks", "");
270         writeElastisearchJsonApi(zos, id, "nodes", "hot_threads");
271     }
272 
273     /**
274      * Writes a specific OpenSearch API response to the ZIP output stream.
275      *
276      * @param zos the ZIP output stream to write to
277      * @param id the diagnostic ID for organizing files in the ZIP
278      * @param v1 the first part of the API path (e.g., "cluster", "nodes")
279      * @param v2 the second part of the API path (e.g., "health", "stats")
280      */
281     protected void writeElastisearchJsonApi(final ZipOutputStream zos, final String id, final String v1, final String v2) {
282         final ZipEntry entry = new ZipEntry(id + "/es_" + v1 + "_" + v2 + ".json");
283         try {
284             zos.putNextEntry(entry);
285             try (CurlResponse response = ComponentUtil.getCurlHelper().get("/_" + v1 + "/" + v2).execute()) {
286                 CopyUtil.copy(response.getContentAsStream(), zos);
287             }
288         } catch (final Exception e) {
289             logger.warn("Failed to access /_{}/{}", v1, v2, e);
290         }
291     }
292 
293     /**
294      * Writes OpenSearch CAT API responses to the ZIP output stream.
295      *
296      * @param zos the ZIP output stream to write to
297      * @param id the diagnostic ID for organizing files in the ZIP
298      */
299     protected void writeFesenCat(final ZipOutputStream zos, final String id) {
300         Arrays.stream(CAT_NAMES).forEach(name -> {
301             final ZipEntry entry = new ZipEntry(id + "/es_cat_" + name + ".txt");
302             try {
303                 zos.putNextEntry(entry);
304                 try (CurlResponse response = ComponentUtil.getCurlHelper().get("/_cat/" + name).param("v", "").execute()) {
305                     CopyUtil.copy(response.getContentAsStream(), zos);
306                 }
307             } catch (final Exception e) {
308                 logger.warn("Failed to access /_cat/{}", name, e);
309             }
310         });
311     }
312 
313     /**
314      * Writes Fess configuration properties to the ZIP output stream.
315      *
316      * @param zos the ZIP output stream to write to
317      * @param id the diagnostic ID for organizing files in the ZIP
318      */
319     protected void writeFessConfig(final ZipOutputStream zos, final String id) {
320         if (fessConfig instanceof SimpleImpl) {
321             final Properties prop = new Properties();
322             ((SimpleImpl) fessConfig).keySet().stream().forEach(k -> prop.setProperty(k, fessConfig.get(k)));
323 
324             final ZipEntry entry = new ZipEntry(id + "/fess_config.properties");
325             try {
326                 zos.putNextEntry(entry);
327                 prop.store(zos, getHostInfo());
328             } catch (final IOException e) {
329                 logger.warn("Failed to access fess_config.properties.", e);
330             }
331         }
332     }
333 
334     /**
335      * Writes Fess basic configuration data in bulk format to the ZIP output stream.
336      *
337      * @param zos the ZIP output stream to write to
338      * @param id the diagnostic ID for organizing files in the ZIP
339      */
340     protected void writeFessBasicConfig(final ZipOutputStream zos, final String id) {
341         final String index = "fess_basic_config";
342         final ZipEntry entry = new ZipEntry(id + "/fess_basic_config.bulk");
343         try {
344             zos.putNextEntry(entry);
345             SearchEngineUtil.scroll(index, hit -> {
346                 final String data = "{\"index\":{\"_index\":\"" + index + "\",\"_id\":\"" + StringEscapeUtils.escapeJson(hit.getId())
347                         + "\"}}\n" + hit.getSourceAsString() + "\n";
348                 try {
349                     zos.write(data.getBytes(Constants.CHARSET_UTF_8));
350                 } catch (final IOException e) {
351                     logger.warn("Failed to access /{}/{}.", index, hit.getId(), e);
352                 }
353                 return true;
354             });
355         } catch (final IOException e) {
356             logger.warn("Failed to access /{}.", index, e);
357         }
358     }
359 
360     /**
361      * Writes system properties to the ZIP output stream.
362      *
363      * @param zos the ZIP output stream to write to
364      * @param id the diagnostic ID for organizing files in the ZIP
365      */
366     protected void writeSystemProperties(final ZipOutputStream zos, final String id) {
367         final ZipEntry entry = new ZipEntry(id + "/system.properties");
368         try {
369             zos.putNextEntry(entry);
370             ComponentUtil.getSystemProperties().store(zos, getHostInfo());
371         } catch (final IOException e) {
372             logger.warn("Failed to access system.properties.", e);
373         }
374     }
375 
376     /**
377      * Writes log files from the log directory to the ZIP output stream.
378      *
379      * @param zos the ZIP output stream to write to
380      * @param id the diagnostic ID for organizing files in the ZIP
381      */
382     protected void writeLogFiles(final ZipOutputStream zos, final String id) {
383         final String logFilePath = systemHelper.getLogFilePath();
384         if (StringUtil.isNotBlank(logFilePath)) {
385             final Path logDirPath = Paths.get(logFilePath);
386             try (Stream<Path> stream = Files.list(logDirPath)) {
387                 stream.filter(entry -> isLogFilename(entry.getFileName().toString())).forEach(filePath -> {
388                     final ZipEntry entry = new ZipEntry(id + "/" + filePath.getFileName().toString());
389                     try {
390                         zos.putNextEntry(entry);
391                         final long len = Files.copy(filePath, zos);
392                         if (logger.isDebugEnabled()) {
393                             logger.debug("Log file: name={}, size={}", filePath.getFileName(), len);
394                         }
395                     } catch (final IOException e) {
396                         logger.warn("Failed to access {}", filePath, e);
397                     }
398                 });
399             } catch (final Exception e) {
400                 logger.warn("Failed to access log files.", e);
401             }
402         }
403     }
404 
405     /**
406      * Gets host information including hostname and IP address.
407      *
408      * @return formatted string containing hostname and IP address
409      */
410     protected String getHostInfo() {
411         final StringBuilder buf = new StringBuilder();
412         try {
413             final InetAddress ia = InetAddress.getLocalHost();
414             final String hostname = ia.getHostName();
415             if (StringUtil.isNotBlank(hostname)) {
416                 buf.append(hostname);
417             }
418             final String ip = IpAddressUtil.getUrlHost(ia);
419             if (StringUtil.isNotBlank(ip)) {
420                 if (buf.length() > 0) {
421                     buf.append(" : ");
422                 }
423                 buf.append(ip);
424             }
425         } catch (final Exception e) {
426             // ignore
427         }
428         return buf.toString();
429     }
430 
431     /**
432      * Checks if the given filename is a log file based on its extension.
433      *
434      * @param name the filename to check
435      * @return true if the file is a log file (.log or .log.gz), false otherwise
436      */
437     protected boolean isLogFilename(final String name) {
438         return name.endsWith(".log") || name.endsWith(".log.gz");
439     }
440 
441     /**
442      * Rebuilds selected configuration indices with the latest mappings.
443      * Executes asynchronously in a background thread.
444      *
445      * @param form the action form containing the target index checkboxes and loadBulkData flag
446      * @return HTML response redirecting to the maintenance page
447      */
448     @Execute
449     @Secured({ ROLE })
450     public HtmlResponse reindexConfigIndices(final ActionForm form) {
451         validate(form, messages -> {}, this::asIndexHtml);
452         verifyToken(this::asIndexHtml);
453 
454         final boolean loadBulkData = isCheckboxEnabled(form.loadBulkData);
455         final Set<String> targetPrefixes = new HashSet<>();
456         if (isCheckboxEnabled(form.rebuildConfigIndex)) {
457             targetPrefixes.add("fess_config");
458         }
459         if (isCheckboxEnabled(form.rebuildUserIndex)) {
460             targetPrefixes.add("fess_user");
461         }
462         if (isCheckboxEnabled(form.rebuildLogIndex)) {
463             targetPrefixes.add("fess_log");
464         }
465 
466         if (targetPrefixes.isEmpty()) {
467             saveError(messages -> messages.addErrorsNoTargetIndexSelected(GLOBAL));
468             return redirect(getClass());
469         }
470 
471         final CoordinatorHelper coordinator = ComponentUtil.getCoordinatorHelper();
472         if (!coordinator.tryStartOperation("reindex_config")) {
473             saveError(messages -> messages.addErrorsOperationAlreadyRunning(GLOBAL,
474                     coordinator.getOperationInfo("reindex_config").map(o -> o.hostname).orElse("unknown")));
475             return redirect(getClass());
476         }
477 
478         new Thread(() -> {
479             try {
480                 if (!searchEngineClient.reindexConfigIndices(loadBulkData, targetPrefixes)) {
481                     logger.warn("Failed to rebuild config indices");
482                 }
483             } catch (final Exception e) {
484                 logger.warn("Failed to rebuild config indices", e);
485             } finally {
486                 coordinator.completeOperation("reindex_config");
487             }
488         }, "rebuild-config-indices").start();
489 
490         saveInfo(messages -> messages.addSuccessStartedDataUpdate(GLOBAL));
491         return redirect(getClass());
492     }
493 
494     /**
495      * Starts a reindex operation with the specified parameters.
496      *
497      * @param replaceAliases whether to replace aliases after reindexing
498      * @param resetDictionaries whether to reset dictionaries during reindexing
499      * @param numberOfShards the number of shards for the new index
500      * @param autoExpandReplicas the auto expand replicas setting for the new index
501      * @return true if the reindex operation started successfully, false otherwise
502      */
503     protected boolean startReindex(final boolean replaceAliases, final boolean resetDictionaries, final String numberOfShards,
504             final String autoExpandReplicas) {
505         final CoordinatorHelper coordinator = ComponentUtil.getCoordinatorHelper();
506         if (!coordinator.tryStartOperation("reindex")) {
507             saveError(messages -> messages.addErrorsOperationAlreadyRunning(GLOBAL,
508                     coordinator.getOperationInfo("reindex").map(o -> o.hostname).orElse("unknown")));
509             return false;
510         }
511 
512         final String docIndex = "fess";
513         final String fromIndex = fessConfig.getIndexDocumentUpdateIndex();
514         final String toIndex = docIndex + "." + new SimpleDateFormat(Constants.DOCUMENT_INDEX_SUFFIX_PATTERN).format(new Date());
515         if (searchEngineClient.createIndex(docIndex, toIndex, numberOfShards, autoExpandReplicas, resetDictionaries)) {
516             try {
517                 searchEngineClient.admin()
518                         .cluster()
519                         .prepareHealth(toIndex)
520                         .setWaitForYellowStatus()
521                         .execute(ActionListener.wrap(response -> {
522                             try {
523                                 searchEngineClient.addMapping(docIndex, "doc", toIndex);
524                                 if (searchEngineClient.copyDocIndex(fromIndex, toIndex, replaceAliases) && replaceAliases
525                                         && !searchEngineClient.updateAlias(toIndex)) {
526                                     logger.warn("Failed to update aliases for {} and {}", fromIndex, toIndex);
527                                 }
528                             } finally {
529                                 coordinator.completeOperation("reindex");
530                             }
531                         }, e -> {
532                             coordinator.completeOperation("reindex");
533                             logger.warn("Failed to reindex from {} to {}", fromIndex, toIndex, e);
534                         }));
535             } catch (final Exception e) {
536                 coordinator.completeOperation("reindex");
537                 throw e;
538             }
539             return true;
540         }
541         coordinator.completeOperation("reindex");
542         saveError(messages -> messages.addErrorsFailedToReindex(GLOBAL, fromIndex, toIndex));
543         return false;
544     }
545 
546 }