Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add official Docker image and optional CORS filter #225

Merged
merged 1 commit into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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="[email protected]" \
ERDDAP_emailDailyReportsTo="[email protected]" \
ERDDAP_emailFromAddress="[email protected]" \
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="[email protected]"

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 String deploymentInfo;

public static String[] DEFAULT_displayAttributeAr = {"summary", "license"};
public static String[] DEFAULT_displayInfoAr = {"Summary", "License"};
Expand Down Expand Up @@ -2300,11 +2305,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
Loading