Skip to content

Commit

Permalink
Add official Docker image and optional CORS filter
Browse files Browse the repository at this point in the history
Adapts development Dockerfile from @roje-bodc and @MattHopsonNOC
and Axiom Dockerfile to a new official ERDDAP Docker image.

Also adds an optional internally managed CORS filter (enabled
by setting `enableCors=true` in settings).

CORS filter behavior can be modified using environment
variables CORS_ALLOW_HEADERS and CORS_ALLOW_ORIGIN.
  • Loading branch information
srstsavage committed Jan 24, 2025
1 parent 625c619 commit 87e8491
Show file tree
Hide file tree
Showing 16 changed files with 529 additions and 468 deletions.
101 changes: 101 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# ERDDAP™ Docker Image

The Dockerfile included in this project builds the offical ERDDAP™ Docker image.
The Dockerfile uses [Apache Maven](https://maven.apache.org/) to package the application into a WAR file,
and serves the application using [Apache Tomcat](https://tomcat.apache.org/).

By default the local ERDDAP source code is used to build the image, but arbitrary git
repositories and branches can alternately be used in the build.

## Building the image

To build the docker image you can run the following command from the root of the ERDDAP™ project:

```bash
docker build -t erddap-docker .
```

The initial build of ERDDAP™ may take a fair amount of time, but the Dockerfile uses cache mounts
in order to speed up subsequent builds of the application by caching dependencies.
It is worth noting that the ERDDAP™ unit tests are ran as part of the build stage, while
integration tests are skipped.

### Building from git

To build an image with source code from a specific git repository and branch instead of the local
source, set build arguments `BUILD_FROM_GIT=1`, `ERDDAP_GIT_URL=<url_to_repo>`,
and `ERDDAP_GIT_BRANCH=<tag_or_branch>`. If `ERDDAP_GIT_BRANCH` is not a tag and is a branch
whose contents can change over time, `ERDDAP_GIT_CACHE_BUST` should also be set to a unique value
to force Docker to not cache a previous build layer and instead fetch and build the source.

Example:

```
docker build --build-arg BUILD_FROM_GIT=1 \
--build-arg ERDDAP_GIT_URL=https://github.com/someuser/erddap \
--build-arg ERDDAP_GIT_BRANCH=experimental-feature-3 \
--build-arg ERDDAP_GIT_CACHE_BUST=$(date +%s) \
-t erddap-docker:experimental-feature-3 .
```

## Running the image
Once the image has been built, the following command can be used run an ERDDAP&trade; container:

```bash
docker run -p 8080:8080 erddap-docker
```

The `--detach` or `-d` flag can be added to detach this process from your terminal.

ERDDAP&trade; will then be accessible at the URL `http://localhost:8080/erddap`.

## Running with Docker Compose

An example Docker Compose stack is provided in `docker-compose.yml`. This stack will
serve the default ERDDAP&trade; demonstration datasets unless a `datasets.xml` file is
mounted as a volume to `/usr/local/tomcat/content/erddap/datasets.xml`.

To build or rebuild the image:

```
docker compose build
```

To run the stack:

```
docker compose up -d
```

An ERDDAP&trade; instance should then be available at <http://localhost:8080>.

To view and tail Tomcat and ERDDAP&trade; logs:

```
docker compose logs -f
```

To shut down the stack:

```
docker compose down
```

Many options can be customized by setting environment variables (`ERDDAP_PORT` etc).
See the `docker-compose.yml` file for details.

## Config

By default generic setup values are set in the Docker image. You can and should customize those values
using [environment variables](https://github.com/ERDDAP/erddap/blob/main/DEPLOY_INSTALL.md#setupEnvironmentVariables)
and/or a custom `setup.xml` file mounted to `/usr/local/tomcat/content/erddap/setup.xml`

For example, to set the ERDDAP&trade; base URL, set environment variable `ERDDAP_baseUrl=http://yourhost:8080`
on the Docker container.

```
docker run -p 8080:8080 -e ERDDAP_baseUrl=http://yourhost:8080` erddap-docker
```

Similarly, the default ERDDAP&trade; demonstration datasets will be served unless a custom `datasets.xml`
file is mounted as a volume to `/usr/local/tomcat/content/erddap/datasets.xml`.
90 changes: 90 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Build the ERDDAP war from source
FROM maven:3.9.6-eclipse-temurin-21 AS build

# install zip so certain tests can pass
RUN apt-get update && \
apt-get install -y --no-install-recommends git zip

WORKDIR /app/

# Copy in source files and build the war file.
COPY development ./development
COPY download ./download
COPY images ./images
COPY src ./src
COPY WEB-INF ./WEB-INF
COPY .mvn ./.mvn
COPY pom.xml .

# if BUILD_FROM_GIT == 1, use code from git clone instead of local source
ARG BUILD_FROM_GIT=0
ARG ERDDAP_GIT_URL=https://github.com/ERDDAP/erddap.git
ARG ERDDAP_GIT_BRANCH=main
ARG ERDDAP_GIT_CACHE_BUST=1
RUN if [ "$BUILD_FROM_GIT" = "1" ] && [ -n "$ERDDAP_GIT_URL" ] && [ -n "$ERDDAP_GIT_BRANCH" ]; then \
find . -mindepth 1 -delete; \
git clone ${ERDDAP_GIT_URL} --depth 1 --branch ${ERDDAP_GIT_BRANCH} .; \
fi

ARG SKIP_TESTS=false
RUN --mount=type=cache,id=m2_repo,target=/root/.m2/repository \
mvn --batch-mode -DskipTests=${SKIP_TESTS} -Dgcf.skipInstallHooks=true \
-Ddownload.unpack=true -Ddownload.unpackWhenChanged=false \
-Dmaven.test.redirectTestOutputToFile=true package \
&& find target -maxdepth 1 -type d -name 'ERDDAP-*' -exec mv {} target/ERDDAP \;

# Run the built erddap war via a tomcat instance
FROM tomcat:10.1.19-jdk21-temurin-jammy

RUN apt-get update && apt-get install -y \
gosu \
unzip \
zip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Remove default Tomcat web applications
RUN rm -rf ${CATALINA_HOME}/webapps/* ${CATALINA_HOME}/webapps.dist

COPY --from=build /app/content /usr/local/tomcat/content
COPY --from=build /app/target/ERDDAP /usr/local/tomcat/webapps/erddap

# Redirect root path / to /erddap
RUN mkdir "${CATALINA_HOME}/webapps/ROOT" \
&& echo '<% response.sendRedirect("/erddap"); %>' > "${CATALINA_HOME}/webapps/ROOT/index.jsp"

COPY ./docker/tomcat/conf/server.xml ./docker/tomcat/conf/context.xml "${CATALINA_HOME}/conf/"
COPY ./docker/tomcat/bin/setenv.sh "${CATALINA_HOME}/bin/"

# Set placeholder values for setup.xml
ENV ERDDAP_deploymentInfo="docker" \
ERDDAP_bigParentDirectory="/erddapData" \
ERDDAP_baseUrl="http://localhost:8080" \
ERDDAP_baseHttpsUrl="https://localhost:8443" \
ERDDAP_emailEverythingTo="set-me@domain.com" \
ERDDAP_emailDailyReportsTo="set-me@domain.com" \
ERDDAP_emailFromAddress="set-me@domain.com" \
ERDDAP_emailUserName="" \
ERDDAP_emailPassword="" \
ERDDAP_emailProperties="" \
ERDDAP_emailSmtpHost="" \
ERDDAP_emailSmtpPort="" \
ERDDAP_adminInstitution="Set-me Institution" \
ERDDAP_adminInstitutionUrl="https://set-me.invalid" \
ERDDAP_adminIndividualName="Firstname Surname" \
ERDDAP_adminPosition="ERDDAP Administrator" \
ERDDAP_adminPhone="555-555-5555" \
ERDDAP_adminAddress="123 Simons Ave." \
ERDDAP_adminCity="Anywhere" \
ERDDAP_adminStateOrProvince="MD" \
ERDDAP_adminPostalCode="12345" \
ERDDAP_adminCountry="USA" \
ERDDAP_adminEmail="set-me@domain.com"

ENV ERDDAP_VERSION_SUFFIX="docker"

COPY ./docker/entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 8080
CMD ["catalina.sh", "run"]
5 changes: 5 additions & 0 deletions WEB-INF/classes/com/cohort/util/String2.java
Original file line number Diff line number Diff line change
Expand Up @@ -6215,6 +6215,11 @@ public static String toSentenceCase(String s) {
return sb.toString();
}

/** Simple null-safe lower case string transformation. */
public static String toLowerCase(String s) {
return s == null ? null : s.toLowerCase();
}

/**
* This suggests a camel-case variable name.
*
Expand Down
31 changes: 28 additions & 3 deletions WEB-INF/classes/gov/noaa/pfel/erddap/Erddap.java
Original file line number Diff line number Diff line change
Expand Up @@ -13213,15 +13213,40 @@ public void doSetDatasetFlag(
public void doVersion(HttpServletRequest request, HttpServletResponse response) throws Throwable {
// see also EDD.flagUrl()

// generate text response
// determine if response should be text or json response
// requests with header Accept: application/json or query parameter format=json will get json
// response
String acceptHeader = request.getHeader("Accept");
String formatParameter = request.getParameter("format");
String extension = ".txt";
boolean isJsonResponse = false;
if ((String2.isSomething(acceptHeader) && acceptHeader.equalsIgnoreCase("application/json"))
|| (String2.isSomething(formatParameter) && formatParameter.equalsIgnoreCase("json"))) {
isJsonResponse = true;
extension = ".json";
}

// generate response
OutputStreamSource outSource =
new OutputStreamFromHttpResponse(request, response, "version", ".txt", ".txt");
new OutputStreamFromHttpResponse(request, response, "version", extension, extension);
OutputStream out = outSource.outputStream(File2.UTF_8);
try (Writer writer = File2.getBufferedWriterUtf8(out)) {
String ev = EDStatic.erddapVersion;
int po = ev.indexOf('_');
if (po >= 0) ev = ev.substring(0, po);
writer.write("ERDDAP_version=" + ev + "\n");

if (isJsonResponse) {
writer.write(
"""
{
"version": "%s",
"version_full": "%s",
"deployment_info": "%s"
}"""
.formatted(ev, EDStatic.erddapVersion, EDStatic.deploymentInfo));
} else {
writer.write("ERDDAP_version=" + ev + "\n");
}
}
// it calls writer.flush then out.close();
}
Expand Down
68 changes: 68 additions & 0 deletions WEB-INF/classes/gov/noaa/pfel/erddap/http/CorsResponseFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package gov.noaa.pfel.erddap.http;

import com.cohort.util.String2;
import gov.noaa.pfel.erddap.util.EDStatic;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import org.apache.commons.lang3.StringUtils;

/** Add CORS headers to the response if EDStatic.enableCors is true. */
@WebFilter("/*")
public class CorsResponseFilter implements Filter {
public static final String DEFAULT_ALLOW_HEADERS =
"Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent";

@Override
public void doFilter(
ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
if (EDStatic.enableCors) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestOrigin = StringUtils.trim(request.getHeader("Origin"));
if (requestOrigin != null && requestOrigin.equalsIgnoreCase("null")) {
requestOrigin = null;
}

if (EDStatic.corsAllowOrigin == null || EDStatic.corsAllowOrigin.length == 0) {
// If corsAllowOrigin is not set, any origin is allowed
if (String2.isSomething(requestOrigin)) {
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
} else {
response.setHeader("Access-Control-Allow-Origin", "*");
}
} else {
// If corsAllowedOrigin is set, make sure the request origin was provided and is in the
// corsAllowedOrigin list
if (String2.isSomething(requestOrigin)) {
if (Arrays.asList(EDStatic.corsAllowOrigin).contains(requestOrigin.toLowerCase())) {
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
} else {
response.setHeader(
"Access-Control-Allow-Origin", requestOrigin + ".origin-not-allowed.invalid");
}
} else {
response.setHeader("Access-Control-Allow-Origin", "https://origin-not-provided.invalid");
}
}

response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", EDStatic.corsAllowHeaders);

if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.setHeader("Access-Control-Max-Age", "7200");
return;
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
13 changes: 13 additions & 0 deletions WEB-INF/classes/gov/noaa/pfel/erddap/util/EDStatic.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import gov.noaa.pfel.erddap.dataset.GridDataAccessor;
import gov.noaa.pfel.erddap.dataset.OutputStreamFromHttpResponse;
import gov.noaa.pfel.erddap.dataset.TableWriterHtmlTable;
import gov.noaa.pfel.erddap.http.CorsResponseFilter;
import gov.noaa.pfel.erddap.variable.EDV;
import gov.noaa.pfel.erddap.variable.EDVGridAxis;
import io.prometheus.metrics.instrumentation.jvm.JvmMetrics;
Expand Down Expand Up @@ -810,12 +811,16 @@ public static int convertToPublicSourceUrlFromSlashPo(String tFrom) {
public static boolean verbose;
public static boolean useSaxParser;
public static final boolean useEddReflection;
public static boolean enableCors;
public static final String corsAllowHeaders;
public static final String[] corsAllowOrigin;
public static final String[]
categoryAttributes; // as it appears in metadata (and used for hashmap)
public static final String[] categoryAttributesInURLs; // fileNameSafe (as used in URLs)
public static final boolean[] categoryIsGlobal;
public static int variableNameCategoryAttributeIndex = -1;
public static final int logMaxSizeMB;
public static final String deploymentInfo;

public static final String emailSmtpHost;
public static final String emailUserName;
Expand Down Expand Up @@ -2295,11 +2300,19 @@ public static int convertToPublicSourceUrlFromSlashPo(String tFrom) {
convertersActive = getSetupEVBoolean(setup, ev, "convertersActive", true);
useSaxParser = getSetupEVBoolean(setup, ev, "useSaxParser", false);
useEddReflection = getSetupEVBoolean(setup, ev, "useEddReflection", false);
enableCors = getSetupEVBoolean(setup, ev, "enableCors", false);
corsAllowHeaders =
getSetupEVString(setup, ev, "corsAllowHeaders", CorsResponseFilter.DEFAULT_ALLOW_HEADERS);
corsAllowOrigin =
String2.split(
String2.toLowerCase(getSetupEVString(setup, ev, "corsAllowOrigin", (String) null)),
',');
slideSorterActive = getSetupEVBoolean(setup, ev, "slideSorterActive", true);
variablesMustHaveIoosCategory =
getSetupEVBoolean(setup, ev, "variablesMustHaveIoosCategory", true);
warName = getSetupEVString(setup, ev, "warName", "erddap");
useSharedWatchService = getSetupEVBoolean(setup, ev, "useSharedWatchService", true);
deploymentInfo = getSetupEVString(setup, ev, "deploymentInfo", "");

// use Lucence?
if (searchEngine.equals("lucene")) {
Expand Down
Loading

0 comments on commit 87e8491

Please sign in to comment.