blob: 7d7161c9d96287007ca37a2e4b077ed199b2bc5d [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 SF 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.felix.hc.core.impl.filter;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.Hashtable;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.felix.hc.api.HealthCheck;
import org.apache.felix.hc.api.Result;
import org.apache.felix.hc.api.execution.HealthCheckExecutionOptions;
import org.apache.felix.hc.api.execution.HealthCheckExecutionResult;
import org.apache.felix.hc.api.execution.HealthCheckSelector;
import org.apache.felix.hc.core.impl.executor.CombinedExecutionResult;
import org.apache.felix.hc.core.impl.executor.ExtendedHealthCheckExecutor;
import org.apache.felix.hc.core.impl.servlet.ResultTxtVerboseSerializer;
import org.apache.felix.hc.core.impl.util.lang.StringUtils;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
import org.osgi.framework.BundleListener;
import org.osgi.framework.Constants;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceEvent;
import org.osgi.framework.ServiceListener;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.ServiceRegistration;
import org.osgi.framework.startlevel.FrameworkStartLevel;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.context.ServletContextHelper;
import org.osgi.service.http.whiteboard.HttpWhiteboardConstants;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Returns a 503 Service Unavailable Page if certain tags are in non-ok result. */
@Component(service = {} /* Filter registers itself for better control */, immediate = true, configurationPolicy = ConfigurationPolicy.REQUIRE)
@Designate(ocd = ServiceUnavailableFilter.Config.class, factory = true)
public class ServiceUnavailableFilter implements Filter {
private static final Logger LOG = LoggerFactory.getLogger(ServiceUnavailableFilter.class);
private static final String CONTENT_TYPE_HTML = "text/html";
private static final String CONTENT_TYPE_PLAIN = "text/plain";
private static final String CACHE_CONTROL_KEY = "Cache-control";
private static final String CACHE_CONTROL_VALUE = "no-cache";
private static final String CLASSPATH_PREFIX = "classpath:";
private static final String CONTEXT_NAME = "internal.http.serviceunavailablefilter";
private static final String PROP_STARTUP_CONTEXT_SERVICE_RANKING = "avoid404DuringStartup.contextServiceRanking";
private static final String PROP_STARTUP_SERVLET_SERVICE_RANKING = "avoid404DuringStartup.servletServiceRanking";
@ObjectClassDefinition(name = "Health Check Service Unavailable Filter", description = "Returns a 503 Service Unavailable Page if configured tags are in non-ok result")
public @interface Config {
String RESPONSE_TEXT_DEFAULT = "<html><head><title>Service Unavailable</title><meta http-equiv=\"refresh\" content=\"5\"></head><body><strong>Service Unavailable</strong></body></html>";
@AttributeDefinition(name = "Filter Request Path RegEx", description = "Regex to be matched against request path. Either use regex or pattern.")
String osgi_http_whiteboard_filter_regex();
@AttributeDefinition(name = "Filter Context", description = "Needs to be set to correct whiteboard context filter (e.g. '(osgi.http.whiteboard.context.name=default)'")
String osgi_http_whiteboard_context_select() default "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=*)";
@AttributeDefinition(name = "Tags", description = "List of tags to query the status in order to decide if it is 503 or not")
String[] tags() default {};
@AttributeDefinition(name = "Status for 503 response", description = "First status that causes a 503 response. The default TEMPORARILY_UNAVAILABLE will not send 503 for OK and WARN but for TEMPORARILY_UNAVAILABLE, CRITICAL and HEALTH_CHECK_ERROR")
Result.Status statusFor503() default Result.Status.TEMPORARILY_UNAVAILABLE;
@AttributeDefinition(name = "Include execution result in response", description = "Will include the execution result in the response (as html comment for html case, otherwise as text).")
boolean includeExecutionResult() default false;
@AttributeDefinition(name = "503 Response Text", description = "Response text for 503 responses. Value can be either the content directly or in the format '"+CLASSPATH_PREFIX+"<symbolic-bundle-id>:/path/to/file.html'. The response content type is auto-detected to either text/html or text/plain.")
String responseTextFor503() default RESPONSE_TEXT_DEFAULT;
@AttributeDefinition(name = "Auto-disable filter", description = "If true, will automatically disable the filter once the filter continued the filter chain without 503 for the first time. Useful for server startup scenarios.")
boolean autoDisableFilter() default false;
@AttributeDefinition(name = "Avoid 404", description = "If true, will automatically register a dummy servlet to ensure this filter becomes effective. Useful for server startup scenarios.")
boolean avoid404DuringStartup() default false;
@AttributeDefinition(name = "Filter Service Ranking", description = "The service.ranking for the filter as respected by http whiteboard.")
int service_ranking() default Integer.MAX_VALUE;
@AttributeDefinition
String webconsole_configurationFactory_nameHint() default "Send 503 for tags {tags} at status {statusFor503} (and worse) for path(s) {osgi.http.whiteboard.filter.regex}";
}
private String[] tags;
private Result.Status statusFor503;
private String responseTextFor503;
private boolean includeExecutionResultInResponse;
private boolean autoDisableFilter;
private boolean avoid404DuringStartup;
@Reference
private ExtendedHealthCheckExecutor executor;
@Reference
ResultTxtVerboseSerializer verboseTxtSerializer;
private BundleContext bundleContext;
private Dictionary<String, Object> compProperties;
private ServiceListener healthCheckServiceListener;
private volatile ServiceRegistration<Filter> filterServiceRegistration;
private volatile ServiceRegistration<ServletContextHelper> httpContextRegistration;
private volatile ServiceRegistration<Servlet> defaultServletRegistration;
private volatile ServiceReference<HealthCheck>[] relevantHealthCheckServiceReferences;
@Activate
protected final void activate(BundleContext bundleContext, ComponentContext componentContext, Config config)
throws InvalidSyntaxException {
this.bundleContext = bundleContext;
this.compProperties = componentContext.getProperties();
this.tags = config.tags();
this.statusFor503 = config.statusFor503();
this.responseTextFor503 = getResponseText(bundleContext, config.responseTextFor503());
this.includeExecutionResultInResponse = config.includeExecutionResult();
this.autoDisableFilter = config.autoDisableFilter();
this.avoid404DuringStartup = config.avoid404DuringStartup();
registerHealthCheckServiceListener();
selectHcServiceReferences();
registerFilter();
LOG.info("ServiceUnavailableFilter active (start level {})", getCurrentStartLevel());
}
@Deactivate
protected final void deactivate() {
unregisterHealthCheckServiceListener();
unregisterFilter();
LOG.info("ServiceUnavailableFilter deactivated");
}
String getResponseText(BundleContext bundleContext, String responseFor503) {
if(StringUtils.isBlank(responseFor503)) {
responseFor503 = (String) compProperties.get("htmlFor503"); // backwards-compatibility
}
if(responseFor503 != null && responseFor503.startsWith(CLASSPATH_PREFIX)) {
String[] bits = responseFor503.split(":");
String symbolicName = bits[1];
String pathInBundle = bits[2];
Optional<Bundle> bundleOptional = Arrays.stream(bundleContext.getBundles()).filter(b -> b.getSymbolicName().equals(symbolicName)).findFirst();
if(bundleOptional.isPresent()) {
URL entryUrl = bundleOptional.get().getEntry(pathInBundle);
if(entryUrl!=null) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(entryUrl.openStream(), StandardCharsets.UTF_8))) {
responseFor503 = reader.lines().collect(Collectors.joining("\n"));
} catch (Exception e) {
responseFor503 = "503 Service Unavailable\n(Could not read '"+pathInBundle+"' from bundle '"+symbolicName+"': "+e+")";
}
} else {
responseFor503 = "503 Service Unavailable\n(Could not read '"+pathInBundle+"' from bundle '"+symbolicName+"': file not found)";
}
} else {
responseFor503 = "503 Service Unavailable\n(Could not read '"+pathInBundle+"' from bundle '"+symbolicName+"': bundle not found)";
}
}
return responseFor503;
}
private int getCurrentStartLevel() {
return bundleContext.getBundle(Constants.SYSTEM_BUNDLE_ID).adapt(FrameworkStartLevel.class).getStartLevel();
}
private synchronized void registerFilter() {
if (filterServiceRegistration == null) {
if (avoid404DuringStartup) {
registerHttpContext();
}
filterServiceRegistration = bundleContext.registerService(Filter.class, this, compProperties);
LOG.debug("Registered ServiceUnavailableFilter for tags {}", Arrays.asList(tags));
if (autoDisableFilter) {
new UnregisteringFilterThread();
LOG.debug("Started UnregisteringFilterThread since autoDisableFilter=true");
}
}
}
private synchronized void unregisterFilter() {
if (filterServiceRegistration != null) {
filterServiceRegistration.unregister();
filterServiceRegistration = null;
LOG.debug("Filter ServiceUnavailableFilter for tags {} unregistered", Arrays.asList(tags));
if (avoid404DuringStartup) {
unregisterHttpContext();
}
}
}
// using ServiceListener and ExtendedHealthCheckExecutor to avoid overhead of searching the service references on every request
private synchronized void selectHcServiceReferences() {
LOG.debug("Reloading HC references for tags {}", Arrays.asList(tags));
relevantHealthCheckServiceReferences = executor.selectHealthCheckReferences(HealthCheckSelector.tags(tags), new HealthCheckExecutionOptions().setCombineTagsWithOr(true));
LOG.debug("Found {} health check service references for tags {}", relevantHealthCheckServiceReferences.length, tags);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
CombinedExecutionResult combinedExecutionResult = executeRelevantChecks(false);
if (is503Result(combinedExecutionResult)) {
Result overallResult = combinedExecutionResult.getHealthCheckResult();
LOG.debug("Result for tags {} is {}, sending 503 for {}", tags, overallResult.getStatus(),
((HttpServletRequest) request).getRequestURI());
String verboseTxtResult = includeExecutionResultInResponse
? verboseTxtSerializer.serialize(overallResult, combinedExecutionResult.getExecutionResults(), false)
: null;
send503((HttpServletResponse) response, verboseTxtResult);
} else {
// regular request processing
filterChain.doFilter(request, response);
}
}
private boolean is503Result(CombinedExecutionResult combinedExecutionResult) {
return combinedExecutionResult.getHealthCheckResult().getStatus().ordinal() >= statusFor503.ordinal();
}
private CombinedExecutionResult executeRelevantChecks(boolean forceInstantExecution) {
final long startTimeNs = System.nanoTime();
List<HealthCheckExecutionResult> executionResults = executor.execute(relevantHealthCheckServiceReferences,
new HealthCheckExecutionOptions().setCombineTagsWithOr(true).setForceInstantExecution(forceInstantExecution));
CombinedExecutionResult combinedExecutionResult = new CombinedExecutionResult(executionResults, Result.Status.TEMPORARILY_UNAVAILABLE);
if (LOG.isDebugEnabled()) {
LOG.debug("Time consumed for executing checks: {}ns", System.nanoTime() - startTimeNs);
}
return combinedExecutionResult;
}
private void send503(HttpServletResponse response, String verboseTxtResult) throws IOException {
if(avoid404DuringStartup && LOG.isDebugEnabled()) {
LOG.debug("Sending 503 at start level {}", getCurrentStartLevel());
}
String responseContent = responseTextFor503;
boolean isHtml = responseContent.contains("<html");
String bodyClosingTag = "</body>";
if(verboseTxtResult != null) {
if(isHtml) {
responseContent = responseContent.replace(bodyClosingTag, "<!--\n\n" + verboseTxtResult + "\n\n-->" + bodyClosingTag);
} else {
responseContent += "\n" + verboseTxtResult;
}
}
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
response.setContentType(isHtml ? CONTENT_TYPE_HTML : CONTENT_TYPE_PLAIN);
response.setHeader(CACHE_CONTROL_KEY, CACHE_CONTROL_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().append(responseContent);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// no action required
}
@Override
public void destroy() {
// no action required
}
// --- HealthCheckServiceListener (to ensure current set of HC service references is up to date
private synchronized void registerHealthCheckServiceListener() throws InvalidSyntaxException {
if (healthCheckServiceListener == null) {
healthCheckServiceListener = new HealthCheckServiceListener();
bundleContext.addServiceListener(healthCheckServiceListener, "(objectclass=" + HealthCheck.class.getName() + ")");
}
}
private synchronized void unregisterHealthCheckServiceListener() {
if (healthCheckServiceListener != null) {
bundleContext.removeServiceListener(healthCheckServiceListener);
}
}
private final class HealthCheckServiceListener implements ServiceListener {
@Override
public void serviceChanged(ServiceEvent event) {
LOG.debug("Service Event for Health Check: {}", event.getType());
selectHcServiceReferences();
}
}
// --- http context for avoid404DuringStartup=true
private void registerHttpContext() {
final Dictionary<String, Object> properties = new Hashtable<>();
properties.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME, CONTEXT_NAME);
properties.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_PATH, "/");
Object contextServiceRanking = compProperties.get(PROP_STARTUP_CONTEXT_SERVICE_RANKING);
properties.put(Constants.SERVICE_RANKING, contextServiceRanking!=null ? contextServiceRanking : Integer.MAX_VALUE);
this.httpContextRegistration = bundleContext.registerService(ServletContextHelper.class, new ServletContextHelper() {
}, properties);
final Dictionary<String, Object> servletProps = new Hashtable<>();
servletProps.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT,
"(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + CONTEXT_NAME + ")");
servletProps.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/");
Object servletServiceRanking = compProperties.get(PROP_STARTUP_SERVLET_SERVICE_RANKING);
servletProps.put(Constants.SERVICE_RANKING, servletServiceRanking!=null ? servletServiceRanking : 0);
this.defaultServletRegistration = bundleContext.registerService(Servlet.class, new HttpServlet() {
private static final long serialVersionUID = 1L;
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
send503((HttpServletResponse) resp, "Response from dynamic startup servlet");
}
}, servletProps);
}
private void unregisterHttpContext() {
if (this.defaultServletRegistration != null) {
this.defaultServletRegistration.unregister();
this.defaultServletRegistration = null;
}
if (this.httpContextRegistration != null) {
this.httpContextRegistration.unregister();
this.httpContextRegistration = null;
}
}
// --- handling for autoDisableFilter=true
public class UnregisteringFilterThread extends Thread {
UnregisteringFilterThread() {
setDaemon(true);
setName("UnregisteringFilterThread for ServiceUnavailableFilter with tags "+Arrays.asList(tags));
start();
}
@Override
public void run() {
while(autoDisableFilter && filterServiceRegistration != null) {
CombinedExecutionResult executionResult = executeRelevantChecks(true);
boolean is503Result = is503Result(executionResult);
if(!is503Result) {
unregisterFilter();
int startLevelWhenFilterUnregistered = getCurrentStartLevel();
LOG.debug("Unregistered filter ServiceUnavailableFilter for tags {} since result was ok at start level {}", Arrays.asList(tags), startLevelWhenFilterUnregistered);
new BundleListenerForReregisteringFilter(bundleContext, startLevelWhenFilterUnregistered);
break;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
LOG.debug("UnregisteringFilterThread for tags {} was interrupted", Arrays.asList(tags));
break;
}
}
}
}
private final class BundleListenerForReregisteringFilter implements BundleListener {
private final Logger LOG = LoggerFactory.getLogger(getClass());
private final int startLevelWhenFilterUnregistered;
public BundleListenerForReregisteringFilter(BundleContext bundleContext, int startLevelWhenFilterUnregistered) {
this.startLevelWhenFilterUnregistered = startLevelWhenFilterUnregistered;
LOG.debug("BundleListenerForReregisteringFilter registered");
bundleContext.addBundleListener(this);
}
@Override
public void bundleChanged(BundleEvent event) {
int currentStartLevel = getCurrentStartLevel();
if(currentStartLevel != startLevelWhenFilterUnregistered) {
LOG.debug("Start level changed (current={} previous={}) - reregistering filter", currentStartLevel, startLevelWhenFilterUnregistered);
registerFilter();
bundleContext.removeBundleListener(this);
LOG.debug("Removed self from BundleListeners");
}
}
}
}