diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..dff99391 --- /dev/null +++ b/DOCKER.md @@ -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=`, +and `ERDDAP_GIT_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™ 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™ 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™ 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™ instance should then be available at . + +To view and tail Tomcat and ERDDAP™ 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™ 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™ demonstration datasets will be served unless a custom `datasets.xml` +file is mounted as a volume to `/usr/local/tomcat/content/erddap/datasets.xml`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ee3722c1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/WEB-INF/classes/com/cohort/util/String2.java b/WEB-INF/classes/com/cohort/util/String2.java index 11a8c796..060ebd34 100644 --- a/WEB-INF/classes/com/cohort/util/String2.java +++ b/WEB-INF/classes/com/cohort/util/String2.java @@ -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. * diff --git a/WEB-INF/classes/gov/noaa/pfel/erddap/Erddap.java b/WEB-INF/classes/gov/noaa/pfel/erddap/Erddap.java index 19ea750c..b6db93c4 100644 --- a/WEB-INF/classes/gov/noaa/pfel/erddap/Erddap.java +++ b/WEB-INF/classes/gov/noaa/pfel/erddap/Erddap.java @@ -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(); } diff --git a/WEB-INF/classes/gov/noaa/pfel/erddap/http/CorsResponseFilter.java b/WEB-INF/classes/gov/noaa/pfel/erddap/http/CorsResponseFilter.java new file mode 100644 index 00000000..f0f21da8 --- /dev/null +++ b/WEB-INF/classes/gov/noaa/pfel/erddap/http/CorsResponseFilter.java @@ -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); + } +} diff --git a/WEB-INF/classes/gov/noaa/pfel/erddap/util/EDStatic.java b/WEB-INF/classes/gov/noaa/pfel/erddap/util/EDStatic.java index cba49a8b..323a022e 100644 --- a/WEB-INF/classes/gov/noaa/pfel/erddap/util/EDStatic.java +++ b/WEB-INF/classes/gov/noaa/pfel/erddap/util/EDStatic.java @@ -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; @@ -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"}; @@ -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")) { diff --git a/WEB-INF/web.xml b/WEB-INF/web.xml index aadb1258..301b0a5e 100644 --- a/WEB-INF/web.xml +++ b/WEB-INF/web.xml @@ -28,7 +28,7 @@ See https://www.owasp.org/index.php/Testing_for_HTTP_Methods_and_XST_%28OWASP-CM http://www.j2eeprogrammer.com/2011/11/disabling-certain-http-methods-in.html Use netcat to test if this works: nc 127.0.0.1 8080[Enter] //or some other domain - OPTIONS / HTTP/1.1[Enter] + TRACE / HTTP/1.1[Enter] Host: 127.0.0.1[Enter] //or some other domain [Enter] //If you get HTTP/1.1 403 Forbidden, the constraint is in effect. Yea! @@ -36,15 +36,14 @@ Use netcat to test if this works: ^C //exits netcat --> - -restricted methods -/* -TRACE -PUT -OPTIONS -DELETE - - + + restricted methods + /* + TRACE + PUT + DELETE + + + + CorsResponseFilter + gov.noaa.pfel.erddap.http.CorsResponseFilter + + + CorsResponseFilter + /* + + metrics io.prometheus.metrics.exporter.servlet.jakarta.PrometheusMetricsServlet diff --git a/development/docker/Dockerfile b/development/docker/Dockerfile deleted file mode 100644 index b4a5d4bc..00000000 --- a/development/docker/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -# This docker file is currently just meant for local development use. -# -# Usage from project root: -# docker build -f development/Dockerfile -t erddap-docker . -# docker run -p 8080:8080 erddap-docker -# -# ERDDAP should then be available at http://localhost:8080/erddap - -# Set up build env. -FROM maven:3.9.6-eclipse-temurin-21 AS build - -RUN mkdir /app/ -WORKDIR /app/ - -# Temp dir required for a unit test. -RUN mkdir /temp/ - -# Copy in source files and build the war file. -ADD development ./development -ADD download ./download -ADD images ./images -ADD src ./src -ADD WEB-INF ./WEB-INF -ADD .mvn ./.mvn -COPY pom.xml . -RUN --mount=type=cache,id=m2_repo,target=/root/.m2/repository mvn package -Dgcf.skipInstallHooks - -# Run the built erddap war via a tomcat instance. -FROM tomcat:10.1.19-jdk21-temurin-jammy -RUN mkdir /usr/local/tomcat/content/erddap/ -p -RUN mkdir /usr/local/erddap_data/ -COPY --from=build /app/target/*.war /usr/local/tomcat/webapps/erddap.war -COPY --from=build /app/content/erddap/* /usr/local/tomcat/content/erddap/ -COPY ./development/docker/config/localSetup.xml /usr/local/tomcat/content/erddap/setup.xml -CMD ["catalina.sh", "run"] diff --git a/development/docker/README.md b/development/docker/README.md deleted file mode 100644 index 16f69de1..00000000 --- a/development/docker/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# ERDDAP™ Development with Docker/Tomcat - -## DockerFile -For development purposes only, a DockerFile has been created in order to streamline the building and deploying of an ERDDAP™ instance locally. This DockerFile uses [Apache Maven](https://maven.apache.org/) to package the application into a WAR file, and then [Apache Tomcat](https://tomcat.apache.org/) to serve the application. - -### Building the image: -To build the docker image you can run the following command from the root of the ERDDAP™ project: -```bash -docker build -f development/docker/Dockerfile -t erddap-docker . -``` -The initial build of ERDDA™ 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. - -### Running the image: -Once the image has been built, the following command can be used run an ERDDAP™ container: -```bash -# The --detach or -d flag can be added to detach this process from your terminal. -docker run -p 8080:8080 erddap-docker -``` - -ERDDAP™ will then be accessible at the URL `http://localhost:8080/erddap`. Due to Tomcat having to deploy the WAR file, you may have to wait a minute for ERDDAP™ to be accessible. - -### Config -If required, you can edit the file `development/docker/config/localSetup.xml` to customize your local instance of ERDDAP™. Currently this file contains many placeholder values due to the nature of this DockerFile being intended for development use only. - -### Datasets -Currently the DockerFile uses the default datsets provided by ERDDAP™. Feel free to extend this DockerFile yourself to allow for the use of custom datasets within the container. diff --git a/development/docker/config/localSetup.xml b/development/docker/config/localSetup.xml deleted file mode 100644 index af635d9d..00000000 --- a/development/docker/config/localSetup.xml +++ /dev/null @@ -1,393 +0,0 @@ - - - - - - /usr/local/erddap_data/ - - - http://localhost:8080 - - - - - - username@email.com - - - - - username@email.com - username@email.com - - - localhost - 25 - - - - - Institution Name - http://localhost/ - localhost - Developer - +44 000-000-0000 - Institution, localhost - CityName - ProvinceName - L0 CAL - Country - username@noc.ac.uk - - - true - - - DejaVu Sans - - - this-world-was-being-watched-keenly-and-closely-by-intelligences-greater-than-mans-and-yet-as-mortal-as-his-own - - - - - - - 20 - - - .* - - - true - - - - - - - - - - - - - UEPSHA256 - - - false - - - - original - - - NONE - only accessible to authorized users - NONE - earth science, atmosphere, ocean, biosphere, biology, environment - - - UDUNITS - - - true - true - - - true - - true - - - true - - - true - - - true - - - true - - - - - - - - noaa_simple.gif - noaa20.gif - nlogo.gif - - - - true - - - global:cdm_data_type, global:institution, ioos_category, global:keywords, long_name, standard_name, variableName - - - - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d7a28059 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + erddap: + build: . + ports: + - "${ERDDAP_PORT:-8080}:8080" + - "${ERDDAP_DEBUG_PORT:-8000}:8000" + volumes: + - data:/erddapData + environment: + ERDDAP_MEMORY: "${ERDDAP_MEMORY:-4g}" + ERDDAP_baseUrl: "http://${ERDDAP_HOST:-localhost}:${ERDDAP_PORT:-8080}" + ERDDAP_enableCors: "true" + #ERDDAP_corsAllowOrigin: "https://some-allowed-domain.com,http://this-one-also.org:8080" + #JAVA_OPTS: "-agentlib:jdwp=transport=dt_socket,address=*:8000,server=y,suspend=n" + mem_limit: "${ERDDAP_CONTAINER_MEM_LIMIT:-6g}" + healthcheck: + test: ["CMD", "curl", "-fl", "http://localhost:8080/erddap/index.html"] + interval: 15s + timeout: 5s + retries: 3 + + logs: + image: debian:bookworm-slim + volumes: + - data:/erddapData:ro + mem_limit: 256m + command: tail -F /erddapData/logs/log.txt + +volumes: + data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..217a4c12 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -e + +# preferable to fire up Tomcat via start-tomcat.sh which will start Tomcat with +# security manager, but inheriting containers can also start Tomcat via +# catalina.sh + +if [ "$1" = 'start-tomcat.sh' ] || [ "$1" = 'catalina.sh' ]; then + # generate random flagKeyKey if not set + if [ -z "$ERDDAP_flagKeyKey" ] && grep "CHANGE THIS TO YOUR FAVORITE QUOTE" \ + "${CATALINA_HOME}/content/erddap/setup.xml" &> /dev/null; then + echo "flagKeyKey isn't properly set. Generating a random value." >&2 + export ERDDAP_flagKeyKey=$(cat /proc/sys/kernel/random/uuid) + fi + + USER_ID=${TOMCAT_USER_ID:-1000} + GROUP_ID=${TOMCAT_GROUP_ID:-1000} + + ### + # Tomcat user + ### + # create group for GROUP_ID if one doesn't already exist + if ! getent group $GROUP_ID &> /dev/null; then + groupadd -r tomcat -g $GROUP_ID + fi + # create user for USER_ID if one doesn't already exist + if ! getent passwd $USER_ID &> /dev/null; then + useradd -u $USER_ID -g $GROUP_ID tomcat + fi + # alter USER_ID with nologin shell and CATALINA_HOME home directory + usermod -d "${CATALINA_HOME}" -s /sbin/nologin $(id -u -n $USER_ID) + + ### + # Change CATALINA_HOME ownership to tomcat user and tomcat group + # Restrict permissions on conf + ### + + chown -R $USER_ID:$GROUP_ID ${CATALINA_HOME} && find ${CATALINA_HOME}/conf \ + -type d -exec chmod 755 {} \; -o -type f -exec chmod 400 {} \; + chown -R $USER_ID:$GROUP_ID /erddapData + sync + + ### + # Run executables/shell scripts in /init.d on each container startup + # Inspired by postgres' /docker-entrypoint-initdb.d + # https://github.com/docker-library/docs/blob/master/postgres/README.md#initialization-scripts + # https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh#L156 + ### + if [ -d "/init.d" ]; then + for f in /init.d/*; do + if [ -x "$f" ]; then + echo "Executing $f" + "$f" + elif [[ $f == *.sh ]]; then + echo "Sourcing $f (not executable)" + . "$f" + fi + done + fi + + exec gosu $USER_ID "$@" +fi + +exec "$@" diff --git a/docker/tomcat/bin/setenv.sh b/docker/tomcat/bin/setenv.sh new file mode 100755 index 00000000..13f137ca --- /dev/null +++ b/docker/tomcat/bin/setenv.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +# Configure JAVA_OPTS + +# Useful for setting version specific options +JAVA_MAJOR_VERSION=$(java -version 2>&1 | head -1 | cut -d'"' -f2 | sed '/^1\./s///' | cut -d'.' -f1) + +# Source bin/config.sh if present +if [ -f "${CATALINA_HOME}/bin/config.sh" ]; then + set -o allexport + source "${CATALINA_HOME}/bin/config.sh" + set +o allexport +fi + +# JAVA_OPTS +NORMAL="-server -Djava.awt.headless=true" +HEAP_DUMP="-XX:+HeapDumpOnOutOfMemoryError" + +# Memory +if [ -n "$ERDDAP_MAX_RAM_PERCENTAGE" ]; then + JVM_MEMORY_ARGS="-XX:MaxRAMPercentage=${ERDDAP_MAX_RAM_PERCENTAGE}" +else + ERDDAP_MEMORY="${ERDDAP_MEMORY:-4G}" + JVM_MEMORY_ARGS="-Xms${ERDDAP_MIN_MEMORY:-${ERDDAP_MEMORY}} -Xmx${ERDDAP_MAX_MEMORY:-${ERDDAP_MEMORY}}" +fi + +CONTENT_ROOT="-DerddapContentDirectory=${CATALINA_HOME}/content/erddap/" + +JAVA_OPTS="$JAVA_OPTS $NORMAL $JVM_MEMORY_ARGS $HEAP_DUMP" +echo "ERDDAP running with JAVA_OPTS: $JAVA_OPTS" + +# Show ERDDAP configuration environment variables +ERDDAP_CONFIG=$(env | grep --regexp "^ERDDAP_.*$" | sort) +if [ -n "$ERDDAP_CONFIG" ]; then + echo -e "ERDDAP configured with:\n$ERDDAP_CONFIG" +fi + diff --git a/docker/tomcat/conf/context.xml b/docker/tomcat/conf/context.xml new file mode 100644 index 00000000..a6d3ac10 --- /dev/null +++ b/docker/tomcat/conf/context.xml @@ -0,0 +1,4 @@ + + + + diff --git a/docker/tomcat/conf/server.xml b/docker/tomcat/conf/server.xml new file mode 100644 index 00000000..a3a4f462 --- /dev/null +++ b/docker/tomcat/conf/server.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/jetty/JettyTests.java b/src/test/java/jetty/JettyTests.java index 5859a58f..c467c5ce 100644 --- a/src/test/java/jetty/JettyTests.java +++ b/src/test/java/jetty/JettyTests.java @@ -1,6 +1,8 @@ package jetty; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.cohort.array.Attributes; import com.cohort.array.LongArray; @@ -52,6 +54,10 @@ import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Path; import java.time.Year; import java.util.Arrays; @@ -65,6 +71,7 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.resource.ResourceFactory; +import org.json.JSONObject; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.io.TempDir; @@ -132,6 +139,108 @@ void displayInformation() throws Exception { Test.ensureTrue(results.indexOf("value for att2") > 0, ""); } + /** Test Cors Filter */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @TagJetty + void testCorsFilter(boolean enableCors) throws Exception { + EDStatic.enableCors = enableCors; + + HttpClient client = HttpClient.newHttpClient(); + URI uri = server.getURI().resolve("/erddap/index.html"); + + HttpRequest optionsRequest = + HttpRequest.newBuilder(uri).method("OPTIONS", HttpRequest.BodyPublishers.noBody()).build(); + validateCorsHeaders(client.send(optionsRequest, HttpResponse.BodyHandlers.discarding())); + + HttpRequest getRequest = HttpRequest.newBuilder(uri).GET().build(); + validateCorsHeaders(client.send(getRequest, HttpResponse.BodyHandlers.discarding())); + + HttpRequest postRequest = + HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.noBody()).build(); + validateCorsHeaders(client.send(postRequest, HttpResponse.BodyHandlers.discarding())); + } + + private void validateCorsHeaders(HttpResponse response) { + if (EDStatic.enableCors && response.request().method().equalsIgnoreCase("OPTIONS")) { + assertEquals(204, response.statusCode()); + } else { + assertEquals(200, response.statusCode()); + } + if (EDStatic.enableCors) { + assertTrue(response.headers().firstValue("Access-Control-Allow-Origin").isPresent()); + assertEquals("*", response.headers().firstValue("Access-Control-Allow-Origin").get()); + assertTrue(response.headers().firstValue("Access-Control-Allow-Methods").isPresent()); + assertEquals( + "GET, POST, OPTIONS", + response.headers().firstValue("Access-Control-Allow-Methods").get()); + assertTrue(response.headers().firstValue("Access-Control-Allow-Headers").isPresent()); + assertEquals( + EDStatic.corsAllowHeaders, + response.headers().firstValue("Access-Control-Allow-Headers").get()); + } else { + assertFalse(response.headers().firstValue("Access-Control-Allow-Origin").isPresent()); + assertFalse(response.headers().firstValue("Access-Control-Allow-Methods").isPresent()); + assertFalse(response.headers().firstValue("Access-Control-Allow-Headers").isPresent()); + } + } + + /** Check string and json ERDDAP version responses */ + @org.junit.jupiter.api.Test + @TagJetty + void testErddapVersionResponse() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + + String erddapShortVersion = EDStatic.erddapVersion; + int po = erddapShortVersion.indexOf('_'); + if (po >= 0) erddapShortVersion = erddapShortVersion.substring(0, po); + + // test short version string response + HttpResponse response = + client.send( + HttpRequest.newBuilder(server.getURI().resolve("/erddap/version")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("ERDDAP_version=" + erddapShortVersion + "\n", response.body()); + + // test full version string response + response = + client.send( + HttpRequest.newBuilder(server.getURI().resolve("/erddap/version_string")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("ERDDAP_version_string=" + EDStatic.erddapVersion + "\n", response.body()); + + // test json response using Accept header including deployment info + EDStatic.deploymentInfo = "integration testing with jetty"; + response = + client.send( + HttpRequest.newBuilder(server.getURI().resolve("/erddap/version")) + .GET() + .header("Accept", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + JSONObject jsonResponse = new JSONObject(response.body()); + assertTrue(jsonResponse.has("version")); + assertEquals(erddapShortVersion, jsonResponse.getString("version")); + assertTrue(jsonResponse.has("version_full")); + assertEquals(EDStatic.erddapVersion, jsonResponse.getString("version_full")); + assertTrue(jsonResponse.has("deployment_info")); + assertEquals(EDStatic.deploymentInfo, jsonResponse.getString("deployment_info")); + + // test json response using query parameter including deployment info + response = + client.send( + HttpRequest.newBuilder(server.getURI().resolve("/erddap/version?format=json")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + // should be equal to the previous json response + assertTrue(jsonResponse.similar(new JSONObject(response.body()))); + } + /** Test the metadata */ @org.junit.jupiter.api.Test @TagJetty