blob: b8be6ad278cafc7ae0ec752ab2b135d3edaacaa7 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.storm.daemon.logviewer.handler;
import static j2html.TagCreator.a;
import static j2html.TagCreator.body;
import static j2html.TagCreator.div;
import static j2html.TagCreator.form;
import static j2html.TagCreator.h3;
import static j2html.TagCreator.head;
import static j2html.TagCreator.html;
import static j2html.TagCreator.input;
import static j2html.TagCreator.link;
import static j2html.TagCreator.option;
import static j2html.TagCreator.p;
import static j2html.TagCreator.pre;
import static j2html.TagCreator.select;
import static j2html.TagCreator.text;
import static j2html.TagCreator.title;
import static java.util.stream.Collectors.toCollection;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang.StringEscapeUtils.escapeHtml;
import com.codahale.metrics.Meter;
import j2html.tags.DomContent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import javax.ws.rs.core.Response;
import org.apache.commons.lang.StringUtils;
import org.apache.storm.daemon.logviewer.LogviewerConstant;
import org.apache.storm.daemon.logviewer.utils.DirectoryCleaner;
import org.apache.storm.daemon.logviewer.utils.ExceptionMeterNames;
import org.apache.storm.daemon.logviewer.utils.LogviewerResponseBuilder;
import org.apache.storm.daemon.logviewer.utils.ResourceAuthorizer;
import org.apache.storm.daemon.logviewer.utils.WorkerLogs;
import org.apache.storm.daemon.ui.InvalidRequestException;
import org.apache.storm.daemon.ui.UIHelpers;
import org.apache.storm.daemon.utils.StreamUtil;
import org.apache.storm.daemon.utils.UrlBuilder;
import org.apache.storm.metric.StormMetricsRegistry;
import org.apache.storm.utils.ConfigUtils;
import org.apache.storm.utils.ServerUtils;
import org.jooq.lambda.Unchecked;
public class LogviewerLogPageHandler {
private final Meter numPageRead;
private final Meter numFileOpenExceptions;
private final Meter numFileReadExceptions;
private final Path logRoot;
private final Path daemonLogRoot;
private final WorkerLogs workerLogs;
private final ResourceAuthorizer resourceAuthorizer;
private final DirectoryCleaner directoryCleaner;
/**
* Constructor.
*
* @param logRoot root worker log directory
* @param daemonLogRoot root daemon log directory
* @param workerLogs {@link WorkerLogs}
* @param resourceAuthorizer {@link ResourceAuthorizer}
* @param metricsRegistry The logviewer metrics registry
*/
public LogviewerLogPageHandler(String logRoot, String daemonLogRoot,
WorkerLogs workerLogs,
ResourceAuthorizer resourceAuthorizer,
StormMetricsRegistry metricsRegistry) {
this.logRoot = Paths.get(logRoot).toAbsolutePath().normalize();
this.daemonLogRoot = Paths.get(daemonLogRoot).toAbsolutePath().normalize();
this.workerLogs = workerLogs;
this.resourceAuthorizer = resourceAuthorizer;
this.numPageRead = metricsRegistry.registerMeter("logviewer:num-page-read");
this.numFileOpenExceptions = metricsRegistry.registerMeter(ExceptionMeterNames.NUM_FILE_OPEN_EXCEPTIONS);
this.numFileReadExceptions = metricsRegistry.registerMeter(ExceptionMeterNames.NUM_FILE_READ_EXCEPTIONS);
this.directoryCleaner = new DirectoryCleaner(metricsRegistry);
}
/**
* Enumerate worker log files for given criteria.
*
* @param user username
* @param port worker's port, null for all workers
* @param topologyId topology ID, null for all topologies
* @param callback callbackParameterName for JSONP
* @param origin origin
* @return list of worker logs for given criteria
*/
public Response listLogFiles(String user, Integer port, String topologyId, String callback, String origin) throws IOException {
List<Path> fileResults = null;
if (topologyId == null) {
if (port == null) {
fileResults = workerLogs.getAllLogsForRootDir();
} else {
fileResults = new ArrayList<>();
File[] logRootFiles = logRoot.toFile().listFiles();
if (logRootFiles != null) {
for (File topoDir : logRootFiles) {
File[] topoDirFiles = topoDir.listFiles();
if (topoDirFiles != null) {
for (File portDir : topoDirFiles) {
if (portDir.getName().equals(port.toString())) {
fileResults.addAll(directoryCleaner.getFilesForDir(portDir.toPath()));
}
}
}
}
}
}
} else {
if (port == null) {
fileResults = new ArrayList<>();
Path topoDir = logRoot.resolve(topologyId).toAbsolutePath().normalize();
if (!topoDir.startsWith(logRoot)) {
return LogviewerResponseBuilder.buildSuccessJsonResponse(Collections.emptyList(), callback, origin);
}
if (topoDir.toFile().exists()) {
File[] topoDirFiles = topoDir.toFile().listFiles();
if (topoDirFiles != null) {
for (File portDir : topoDirFiles) {
fileResults.addAll(directoryCleaner.getFilesForDir(portDir.toPath()));
}
}
}
} else {
File portDir = ConfigUtils.getWorkerDirFromRoot(logRoot.toString(), topologyId, port).getCanonicalFile();
if (!portDir.getPath().startsWith(logRoot.toString())) {
return LogviewerResponseBuilder.buildSuccessJsonResponse(Collections.emptyList(), callback, origin);
}
if (portDir.exists()) {
fileResults = directoryCleaner.getFilesForDir(portDir.toPath());
}
}
}
List<String> files;
if (fileResults != null) {
files = fileResults.stream()
.map(WorkerLogs::getTopologyPortWorkerLog)
.sorted().collect(toList());
} else {
files = new ArrayList<>();
}
return LogviewerResponseBuilder.buildSuccessJsonResponse(files, callback, origin);
}
/**
* Provides a worker log file to view, starting from the specified position
* or default starting position of the most recent page.
*
* @param fileName file to view
* @param start start offset, or null if the most recent page is desired
* @param length length to read in this page, or null if default page length is desired
* @param grep search string if request is a result of the search, can be null
* @param user username
* @return HTML view page of worker log
*/
public Response logPage(String fileName, Integer start, Integer length, String grep, String user)
throws IOException, InvalidRequestException {
Path rawFile = logRoot.resolve(fileName);
Path absFile = rawFile.toAbsolutePath().normalize();
if (!absFile.startsWith(logRoot) || !rawFile.normalize().toString().equals(rawFile.toString())) {
//Ensure filename doesn't contain ../ parts
return LogviewerResponseBuilder.buildResponsePageNotFound();
}
if (resourceAuthorizer.isUserAllowedToAccessFile(user, fileName)) {
workerLogs.setLogFilePermission(fileName);
Path topoDir = absFile.getParent().getParent();
if (absFile.toFile().exists()) {
SortedSet<Path> logFiles;
try {
logFiles = Arrays.stream(topoDir.toFile().listFiles())
.flatMap(Unchecked.function(portDir -> directoryCleaner.getFilesForDir(portDir.toPath()).stream()))
.filter(Files::isRegularFile)
.collect(toCollection(TreeSet::new));
} catch (UncheckedIOException e) {
throw e.getCause();
}
List<String> reorderedFilesStr = logFiles.stream()
.map(WorkerLogs::getTopologyPortWorkerLog)
.filter(fileStr -> !StringUtils.equals(fileName, fileStr))
.collect(toList());
reorderedFilesStr.add(fileName);
length = length != null ? Math.min(10485760, length) : LogviewerConstant.DEFAULT_BYTES_PER_PAGE;
final boolean isZipFile = absFile.getFileName().toString().endsWith(".gz");
long fileLength = getFileLength(absFile.toFile(), isZipFile);
if (start == null) {
start = Long.valueOf(fileLength - length).intValue();
}
String logString = isTxtFile(fileName) ? escapeHtml(pageFile(absFile.toString(), isZipFile, fileLength, start, length)) :
escapeHtml("This is a binary file and cannot display! You may download the full file.");
List<DomContent> bodyContents = new ArrayList<>();
if (StringUtils.isNotEmpty(grep)) {
String matchedString = String.join("\n", Arrays.stream(logString.split("\n"))
.filter(str -> str.contains(grep)).collect(toList()));
bodyContents.add(pre(matchedString).withId("logContent"));
} else {
DomContent pagerData = null;
if (isTxtFile(fileName)) {
pagerData = pagerLinks(fileName, start, length, Long.valueOf(fileLength).intValue(), "log");
}
bodyContents.add(searchFileForm(fileName, "no"));
// list all files for this topology
bodyContents.add(logFileSelectionForm(reorderedFilesStr, fileName, "log"));
if (pagerData != null) {
bodyContents.add(pagerData);
}
bodyContents.add(downloadLink(fileName));
bodyContents.add(pre(logString).withClass("logContent"));
if (pagerData != null) {
bodyContents.add(pagerData);
}
}
String content = logTemplate(bodyContents, fileName, user).render();
return LogviewerResponseBuilder.buildSuccessHtmlResponse(content);
} else {
return LogviewerResponseBuilder.buildResponsePageNotFound();
}
} else {
if (resourceAuthorizer.getLogUserGroupWhitelist(fileName) == null) {
return LogviewerResponseBuilder.buildResponsePageNotFound();
} else {
return LogviewerResponseBuilder.buildResponseUnauthorizedUser(user);
}
}
}
/**
* Provides a daemon log file to view.
*
* @param fileName file to view
* @param start start offset, or null if the most recent page is desired
* @param length length to read in this page, or null if default page length is desired
* @param grep search string if request is a result of the search, can be null
* @param user username
* @return HTML view page of daemon log
*/
public Response daemonLogPage(String fileName, Integer start, Integer length, String grep, String user)
throws IOException, InvalidRequestException {
Path file = daemonLogRoot.resolve(fileName).toAbsolutePath().normalize();
if (!file.startsWith(daemonLogRoot) || Paths.get(fileName).getNameCount() != 1) {
//Prevent fileName from pathing into worker logs, or outside daemon log root
return LogviewerResponseBuilder.buildResponsePageNotFound();
}
if (file.toFile().exists()) {
// all types of files included
List<File> logFiles = Arrays.stream(daemonLogRoot.toFile().listFiles())
.filter(File::isFile)
.collect(toList());
List<String> reorderedFilesStr = logFiles.stream()
.map(File::getName)
.filter(fName -> !StringUtils.equals(fileName, fName))
.collect(toList());
reorderedFilesStr.add(fileName);
length = length != null ? Math.min(10485760, length) : LogviewerConstant.DEFAULT_BYTES_PER_PAGE;
final boolean isZipFile = file.getFileName().toString().endsWith(".gz");
long fileLength = getFileLength(file.toFile(), isZipFile);
if (start == null) {
start = Long.valueOf(fileLength - length).intValue();
}
String logString = isTxtFile(fileName) ? escapeHtml(pageFile(file.toString(), isZipFile, fileLength, start, length)) :
escapeHtml("This is a binary file and cannot display! You may download the full file.");
List<DomContent> bodyContents = new ArrayList<>();
if (StringUtils.isNotEmpty(grep)) {
String matchedString = String.join("\n", Arrays.stream(logString.split("\n"))
.filter(str -> str.contains(grep)).collect(toList()));
bodyContents.add(pre(matchedString).withId("logContent"));
} else {
DomContent pagerData = null;
if (isTxtFile(fileName)) {
pagerData = pagerLinks(fileName, start, length, Long.valueOf(fileLength).intValue(), "daemonlog");
}
bodyContents.add(searchFileForm(fileName, "yes"));
// list all daemon logs
bodyContents.add(logFileSelectionForm(reorderedFilesStr, fileName, "daemonlog"));
if (pagerData != null) {
bodyContents.add(pagerData);
}
bodyContents.add(daemonDownloadLink(fileName));
bodyContents.add(pre(logString).withClass("logContent"));
if (pagerData != null) {
bodyContents.add(pagerData);
}
}
String content = logTemplate(bodyContents, fileName, user).render();
return LogviewerResponseBuilder.buildSuccessHtmlResponse(content);
} else {
return LogviewerResponseBuilder.buildResponsePageNotFound();
}
}
private long getFileLength(File file, boolean isZipFile) throws IOException {
try {
return isZipFile ? ServerUtils.zipFileSize(file) : file.length();
} catch (FileNotFoundException e) {
numFileOpenExceptions.mark();
throw e;
} catch (IOException e) {
numFileReadExceptions.mark();
throw e;
}
}
private DomContent logTemplate(List<DomContent> bodyContents, String fileName, String user) {
List<DomContent> finalBodyContents = new ArrayList<>();
if (StringUtils.isNotBlank(user)) {
finalBodyContents.add(div(p("User: " + user)).withClass("ui-user"));
}
finalBodyContents.add(div(p("Note: the drop-list shows at most 1024 files for each worker directory.")).withClass("ui-note"));
finalBodyContents.add(h3(escapeHtml(fileName)));
finalBodyContents.addAll(bodyContents);
return html(
head(
title(escapeHtml(fileName) + " - Storm Log Viewer"),
link().withRel("stylesheet").withHref("/css/bootstrap-3.3.1.min.css"),
link().withRel("stylesheet").withHref("/css/jquery.dataTables.1.10.4.min.css"),
link().withRel("stylesheet").withHref("/css/style.css")
),
body(
finalBodyContents.toArray(new DomContent[]{})
)
);
}
private DomContent downloadLink(String fileName) {
return p(linkTo(UIHelpers.urlFormat("/api/v1/download?file=%s", fileName), "Download Full File"));
}
private DomContent daemonDownloadLink(String fileName) {
return p(linkTo(UIHelpers.urlFormat("/api/v1/daemondownload?file=%s", fileName), "Download Full File"));
}
private DomContent linkTo(String url, String content) {
return a(content).withHref(url);
}
private DomContent logFileSelectionForm(List<String> logFiles, String selectedFile, String type) {
return form(
dropDown("file", logFiles, selectedFile),
input().withType("submit").withValue("Switch file")
).withAction(type).withId("list-of-files");
}
private DomContent dropDown(String name, List<String> logFiles, String selectedFile) {
List<DomContent> options = logFiles.stream()
.map(file -> option(file).condAttr(file.equals(selectedFile), "selected", "selected"))
.collect(toList());
return select(options.toArray(new DomContent[]{})).withName(name).withId(name).withValue(selectedFile);
}
private DomContent searchFileForm(String fileName, String isDaemonValue) {
return form(
text("search this file:"),
input().withType("text").withName("search"),
input().withType("hidden").withName("is-daemon").withValue(isDaemonValue),
input().withType("hidden").withName("file").withValue(fileName),
input().withType("submit").withValue("Search")
).withAction("/logviewer_search.html").withId("search-box");
}
private DomContent pagerLinks(String fileName, Integer start, Integer length, Integer fileLength, String type) {
Map<String, Object> urlQueryParams = new HashMap<>();
urlQueryParams.put("file", fileName);
urlQueryParams.put("start", Math.max(0, start - length));
urlQueryParams.put("length", length);
List<DomContent> btnLinks = new ArrayList<>();
int prevStart = Math.max(0, start - length);
btnLinks.add(toButtonLink(UrlBuilder.build("/api/v1/" + type, urlQueryParams), "Prev", prevStart < start));
urlQueryParams.clear();
urlQueryParams.put("file", fileName);
urlQueryParams.put("start", 0);
urlQueryParams.put("length", length);
btnLinks.add(toButtonLink(UrlBuilder.build("/api/v1/" + type, urlQueryParams), "First"));
urlQueryParams.clear();
urlQueryParams.put("file", fileName);
urlQueryParams.put("length", length);
btnLinks.add(toButtonLink(UrlBuilder.build("/api/v1/" + type, urlQueryParams), "Last"));
urlQueryParams.clear();
urlQueryParams.put("file", fileName);
urlQueryParams.put("start", Math.min(Math.max(0, fileLength - length), start + length));
urlQueryParams.put("length", length);
int nextStart = fileLength > 0 ? Math.min(Math.max(0, fileLength - length), start + length) : start + length;
btnLinks.add(toButtonLink(UrlBuilder.build("/api/v1/" + type, urlQueryParams), "Next", nextStart > start));
return div(btnLinks.toArray(new DomContent[]{}));
}
private DomContent toButtonLink(String url, String text) {
return toButtonLink(url, text, true);
}
private DomContent toButtonLink(String url, String text, boolean enabled) {
return a(text).withHref(url).withClass("btn btn-default " + (enabled ? "enabled" : "disabled"));
}
private String pageFile(String path, boolean isZipFile, long fileLength, Integer start, Integer readLength)
throws IOException, InvalidRequestException {
try (InputStream input = isZipFile ? new GZIPInputStream(new FileInputStream(path)) : new FileInputStream(path);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
if (start >= fileLength) {
throw new InvalidRequestException("Cannot start past the end of the file");
}
if (start > 0) {
StreamUtil.skipBytes(input, start);
}
byte[] buffer = new byte[1024];
while (output.size() < readLength) {
int size = input.read(buffer, 0, Math.min(1024, readLength - output.size()));
if (size > 0) {
output.write(buffer, 0, size);
} else {
break;
}
}
numPageRead.mark();
return output.toString();
} catch (FileNotFoundException e) {
numFileOpenExceptions.mark();
throw e;
} catch (IOException e) {
numFileReadExceptions.mark();
throw e;
}
}
private boolean isTxtFile(String fileName) {
Pattern p = Pattern.compile("\\.(log.*|txt|yaml|pid)$");
Matcher matcher = p.matcher(fileName);
return matcher.find();
}
}