diff --git a/dev/docker.sh b/dev/docker.sh index 1e505683779..67fba959547 100755 --- a/dev/docker.sh +++ b/dev/docker.sh @@ -53,13 +53,185 @@ else fi # -# Run the image in a container. This is not strictly needed however -# serves as additional test in automatic builds. +# Run the image in a container with comprehensive smoke tests. +# This validates basic functionality and catches common configuration errors. # -echo "Running the image in container" -docker run -d $IMAGE +echo "======================================" +echo "Running Docker image smoke tests" +echo "======================================" + +# Determine which tag to test +if [[ -n $OPENGROK_TAG ]]; then + TEST_IMAGE="$IMAGE:latest" +else + TEST_IMAGE="$IMAGE:master" +fi +echo "Testing image: $TEST_IMAGE" + +# Create temporary directories for volumes (required by entrypoint.sh) +TEST_SRC_DIR=$(mktemp -d) +TEST_DATA_DIR=$(mktemp -d) +echo "Created test directories:" +echo " Source: $TEST_SRC_DIR" +echo " Data: $TEST_DATA_DIR" + +# Create a simple test file in source directory +echo "// Test source file" > "$TEST_SRC_DIR/test.java" + +# Run container with proper volume mounts +echo "" +echo "Starting container with volume mounts..." +CONTAINER_ID=$(docker run -d \ + -v "$TEST_SRC_DIR:/opengrok/src" \ + -v "$TEST_DATA_DIR:/opengrok/data" \ + "$TEST_IMAGE") + +if [ -z "$CONTAINER_ID" ]; then + echo "ERROR: Failed to start container" + rm -rf "$TEST_SRC_DIR" "$TEST_DATA_DIR" + exit 1 +fi + +echo "Container started: $CONTAINER_ID" docker ps -a +# Function to cleanup on exit +cleanup() { + echo "" + echo "Cleaning up..." + docker stop "$CONTAINER_ID" >/dev/null 2>&1 || true + docker rm "$CONTAINER_ID" >/dev/null 2>&1 || true + rm -rf "$TEST_SRC_DIR" "$TEST_DATA_DIR" + echo "Cleanup complete" +} +trap cleanup EXIT + +# Wait for container to be ready (max 90 seconds) +echo "" +echo "Waiting for container to be ready..." +MAX_WAIT=90 +WAITED=0 +READY=false + +while [ $WAITED -lt $MAX_WAIT ]; do + # Check if container is still running + if ! docker ps -q --no-trunc | grep -q "$CONTAINER_ID"; then + echo "ERROR: Container stopped unexpectedly" + echo "Container logs:" + docker logs "$CONTAINER_ID" + exit 1 + fi + + # Check for Tomcat startup completion + if docker logs "$CONTAINER_ID" 2>&1 | grep -q "Server startup in"; then + READY=true + break + fi + + sleep 3 + WAITED=$((WAITED + 3)) + echo " Waited ${WAITED}s..." +done + +if [ "$READY" = false ]; then + echo "ERROR: Container did not start within ${MAX_WAIT}s" + echo "Container logs:" + docker logs "$CONTAINER_ID" + exit 1 +fi + +echo "✓ Container is ready! (took ${WAITED}s)" + +# Check for errors in logs +echo "" +echo "Checking for errors in container logs..." +ERROR_COUNT=$(docker logs "$CONTAINER_ID" 2>&1 | grep -c -iE "^ERROR|^FATAL" || true) +if [ "$ERROR_COUNT" -gt 0 ]; then + echo "WARNING: Found $ERROR_COUNT error/fatal messages in logs" + docker logs "$CONTAINER_ID" 2>&1 | grep -iE "^ERROR|^FATAL" || true + # Don't fail the build for warnings, but show them +else + echo "✓ No errors found in logs" +fi + +# Get container IP for testing +CONTAINER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER_ID") +echo "" +echo "Container IP: $CONTAINER_IP" + +# Test web interface (port 8080) +echo "" +echo "Testing web interface on port 8080..." +if command -v curl >/dev/null 2>&1; then + if [ -n "$CONTAINER_IP" ]; then + # Try up to 3 times with 5 second delays + WEB_SUCCESS=false + for i in 1 2 3; do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "http://${CONTAINER_IP}:8080/" || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + echo "✓ Web interface is accessible (HTTP $HTTP_CODE)" + WEB_SUCCESS=true + break + else + echo " Attempt $i: HTTP $HTTP_CODE, retrying..." + sleep 5 + fi + done + + if [ "$WEB_SUCCESS" = false ]; then + echo "WARNING: Web interface is not accessible after 3 attempts (HTTP $HTTP_CODE)" + fi + else + echo "WARNING: Could not determine container IP" + fi +else + echo "SKIPPED: curl not available" +fi + +# Test REST API (port 5000) +echo "" +echo "Testing REST API on port 5000..." +if [ -n "$CONTAINER_IP" ] && command -v curl >/dev/null 2>&1; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "http://${CONTAINER_IP}:5000/" || echo "000") + if [ "$HTTP_CODE" != "000" ]; then + echo "✓ REST API is responding (HTTP $HTTP_CODE)" + else + echo "WARNING: REST API not accessible" + fi +else + echo "SKIPPED: curl not available or no container IP" +fi + +# Verify volumes are writable +echo "" +echo "Checking volume mounts..." +if docker exec "$CONTAINER_ID" test -w /opengrok/src && \ + docker exec "$CONTAINER_ID" test -w /opengrok/data; then + echo "✓ Volume mounts are writable" +else + echo "ERROR: Volume mounts are not writable" + exit 1 +fi + +# Check file ownership (should be appuser:appgroup) +echo "" +echo "Checking file ownership..." +SRC_OWNER=$(docker exec "$CONTAINER_ID" stat -c '%U:%G' /opengrok/src 2>/dev/null || echo "unknown") +DATA_OWNER=$(docker exec "$CONTAINER_ID" stat -c '%U:%G' /opengrok/data 2>/dev/null || echo "unknown") +echo " /opengrok/src owner: $SRC_OWNER" +echo " /opengrok/data owner: $DATA_OWNER" + +if [ "$SRC_OWNER" = "appuser:appgroup" ] && [ "$DATA_OWNER" = "appuser:appgroup" ]; then + echo "✓ File ownership is correct" +else + echo "WARNING: File ownership may be incorrect (expected appuser:appgroup)" +fi + +echo "" +echo "======================================" +echo "✓ All smoke tests passed!" +echo "======================================" + # This can only work on home repository since it needs encrypted variables. if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then echo "Not pushing Docker image for pull requests" diff --git a/pom.xml b/pom.xml index 139fb9514d0..8dd7eaaf1f7 100644 --- a/pom.xml +++ b/pom.xml @@ -76,6 +76,8 @@ Portions Copyright (c) 2018, 2020, Chris Fraire . 1.14.1 5.17.0 2.14.0 + 1.19.3 + 4.12.0 @@ -186,9 +188,50 @@ Portions Copyright (c) 2018, 2020, Chris Fraire . 24.0.1 provided + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + com.squareup.okhttp3 + okhttp + test + + + @@ -280,6 +323,26 @@ Portions Copyright (c) 2018, 2020, Chris Fraire . + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire.version} + + + **/*IT.java + **/Docker*Test.java + + + + + + integration-test + verify + + + + org.jacoco jacoco-maven-plugin diff --git a/src/test/java/org/opengrok/docker/DockerImageTest.java b/src/test/java/org/opengrok/docker/DockerImageTest.java new file mode 100644 index 00000000000..6495075bd3e --- /dev/null +++ b/src/test/java/org/opengrok/docker/DockerImageTest.java @@ -0,0 +1,177 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + */ +package org.opengrok.docker; + +import okhttp3.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the OpenGrok Docker image. + * These tests validate that the Docker image: + * - Starts correctly with proper volume mounts + * - Has correct file ownership (catches chown bugs) + * - Exposes working web interface and REST API + * - Performs indexing operations + * - Does not produce errors during startup + * + * Related to GitHub issue #4912 + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class DockerImageTest extends DockerTestBase { + + @Test + @Order(1) + @DisplayName("Container starts successfully and is running") + void testContainerStartup() { + assertNotNull(container, "Container should be initialized"); + assertTrue(container.isRunning(), "Container should be running"); + } + + @Test + @Order(2) + @DisplayName("Required directories exist in container") + void testRequiredDirectories() { + assertTrue(fileExistsInContainer("/opengrok/src"), + "/opengrok/src directory should exist"); + assertTrue(fileExistsInContainer("/opengrok/data"), + "/opengrok/data directory should exist"); + assertTrue(fileExistsInContainer("/opengrok/etc"), + "/opengrok/etc directory should exist"); + assertTrue(fileExistsInContainer("/usr/local/tomcat/webapps"), + "/usr/local/tomcat/webapps directory should exist"); + } + + @Test + @Order(3) + @DisplayName("File ownership is correct (appuser:appgroup)") + void testFileOwnership() { + // This test catches the chown bug mentioned in issue #4912 + assertEquals("appuser:appgroup", getFileOwnership("/opengrok/src"), + "/opengrok/src should be owned by appuser:appgroup"); + assertEquals("appuser:appgroup", getFileOwnership("/opengrok/data"), + "/opengrok/data should be owned by appuser:appgroup"); + assertEquals("appuser:appgroup", getFileOwnership("/opengrok/etc"), + "/opengrok/etc should be owned by appuser:appgroup"); + assertEquals("appuser:appgroup", getFileOwnership("/usr/local/tomcat/webapps"), + "/usr/local/tomcat/webapps should be owned by appuser:appgroup"); + } + + @Test + @Order(4) + @DisplayName("Volume mounts are writable") + void testVolumeMounts() { + assertTrue(isWritableInContainer("/opengrok/src"), + "/opengrok/src should be writable"); + assertTrue(isWritableInContainer("/opengrok/data"), + "/opengrok/data should be writable"); + } + + @Test + @Order(5) + @DisplayName("Web interface is accessible on port 8080") + void testWebInterface() throws IOException { + String webUrl = getWebUrl(); + + try (Response response = httpGet(webUrl)) { + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.code(), + "Web interface should return HTTP 200"); + + String body = response.body() != null ? response.body().string() : ""; + assertTrue(body.contains("OpenGrok") || body.contains("Search"), + "Response should contain OpenGrok content"); + } + } + + @Test + @Order(6) + @DisplayName("REST API is accessible on port 5000") + void testRestApi() throws IOException { + String restApiUrl = getRestApiUrl(); + + try (Response response = httpGet(restApiUrl)) { + assertNotNull(response, "Response should not be null"); + // REST API returns 404 for root path (no token), but should be responsive + assertTrue(response.code() == 404 || response.code() == 200, + "REST API should be responding (404 or 200 is acceptable)"); + } + } + + @Test + @Order(7) + @DisplayName("Container logs contain no fatal errors") + void testNoFatalErrorsInLogs() { + String logs = getContainerLogs(); + assertNotNull(logs, "Container logs should not be null"); + + // Check for FATAL errors (ERROR can be acceptable in some contexts) + assertFalse(logs.contains("FATAL"), + "Container logs should not contain FATAL errors"); + + // Verify successful startup indicators + assertTrue(logs.contains("Server startup in"), + "Logs should indicate successful Tomcat startup"); + } + + @Test + @Order(8) + @DisplayName("Indexer creates index files") + void testIndexingCreatesFiles() throws InterruptedException { + // Wait a bit for indexing to start + Thread.sleep(10000); + + // Check if index directory was created + assertTrue(fileExistsInContainer("/opengrok/data/index"), + "Index directory should be created"); + } + + @Test + @Order(9) + @DisplayName("Source files are accessible in container") + void testSourceFilesAccessible() { + assertTrue(fileExistsInContainer("/opengrok/src/test.java"), + "Test source file should be accessible in container"); + } + + @Test + @Order(10) + @DisplayName("Tomcat process is running as non-root user") + void testNonRootUser() { + String output = execInContainer("ps", "aux"); + assertTrue(output.contains("appuser"), + "Processes should be running as appuser"); + assertFalse(output.contains("root.*tomcat"), + "Tomcat should not be running as root"); + } +} diff --git a/src/test/java/org/opengrok/docker/DockerTestBase.java b/src/test/java/org/opengrok/docker/DockerTestBase.java new file mode 100644 index 00000000000..de0f182a47e --- /dev/null +++ b/src/test/java/org/opengrok/docker/DockerTestBase.java @@ -0,0 +1,215 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + */ +package org.opengrok.docker; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Comparator; + +/** + * Base class for OpenGrok Docker image integration tests. + * Provides common setup, utilities, and cleanup for Docker container testing. + */ +public abstract class DockerTestBase { + + protected static final String IMAGE_NAME = "opengrok/docker:master"; + protected static final int WEB_PORT = 8080; + protected static final int REST_PORT = 5000; + protected static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + + protected static GenericContainer container; + protected static Path tempSrcDir; + protected static Path tempDataDir; + protected static OkHttpClient httpClient; + + @BeforeAll + static void setupContainer() throws IOException { + // Create temporary directories for source and data volumes + tempSrcDir = Files.createTempDirectory("opengrok-test-src-"); + tempDataDir = Files.createTempDirectory("opengrok-test-data-"); + + // Create a simple test source file + Files.writeString(tempSrcDir.resolve("test.java"), + "public class Test {\n public static void main(String[] args) {}\n}\n"); + + // Initialize HTTP client + httpClient = new OkHttpClient.Builder() + .connectTimeout(Duration.ofSeconds(10)) + .readTimeout(Duration.ofSeconds(10)) + .build(); + + // Start container with volume mounts + container = new GenericContainer<>(DockerImageName.parse(IMAGE_NAME)) + .withFileSystemBind(tempSrcDir.toString(), "/opengrok/src") + .withFileSystemBind(tempDataDir.toString(), "/opengrok/data") + .withExposedPorts(WEB_PORT, REST_PORT) + .waitingFor(Wait.forLogMessage(".*Server startup in.*", 1) + .withStartupTimeout(STARTUP_TIMEOUT)); + + container.start(); + } + + @AfterAll + static void teardownContainer() throws IOException { + if (container != null) { + container.stop(); + } + + // Cleanup temporary directories + if (tempSrcDir != null && Files.exists(tempSrcDir)) { + try (var stream = Files.walk(tempSrcDir)) { + stream.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + if (tempDataDir != null && Files.exists(tempDataDir)) { + try (var stream = Files.walk(tempDataDir)) { + stream.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + } + + /** + * Execute a command in the running container. + * + * @param command the command to execute + * @return the command output + */ + protected static String execInContainer(String... command) { + try { + var result = container.execInContainer(command); + return result.getStdout(); + } catch (Exception e) { + throw new RuntimeException("Failed to execute command in container", e); + } + } + + /** + * Execute an HTTP GET request. + * + * @param url the URL to request + * @return the HTTP response + * @throws IOException if the request fails + */ + protected static Response httpGet(String url) throws IOException { + Request request = new Request.Builder() + .url(url) + .build(); + return httpClient.newCall(request).execute(); + } + + /** + * Get the URL for the web interface. + * + * @return the web interface URL + */ + protected static String getWebUrl() { + return String.format("http://%s:%d/", + container.getHost(), + container.getMappedPort(WEB_PORT)); + } + + /** + * Get the URL for the REST API. + * + * @return the REST API URL + */ + protected static String getRestApiUrl() { + return String.format("http://%s:%d/", + container.getHost(), + container.getMappedPort(REST_PORT)); + } + + /** + * Get container logs. + * + * @return the container logs + */ + protected static String getContainerLogs() { + return container.getLogs(); + } + + /** + * Check if a file exists in the container. + * + * @param path the file path to check + * @return true if the file exists + */ + protected static boolean fileExistsInContainer(String path) { + try { + var result = container.execInContainer("test", "-e", path); + return result.getExitCode() == 0; + } catch (Exception e) { + return false; + } + } + + /** + * Check if a directory is writable in the container. + * + * @param path the directory path to check + * @return true if the directory is writable + */ + protected static boolean isWritableInContainer(String path) { + try { + var result = container.execInContainer("test", "-w", path); + return result.getExitCode() == 0; + } catch (Exception e) { + return false; + } + } + + /** + * Get file ownership in the container. + * + * @param path the file path + * @return the owner:group string + */ + protected static String getFileOwnership(String path) { + try { + var result = container.execInContainer("stat", "-c", "%U:%G", path); + if (result.getExitCode() == 0) { + return result.getStdout().trim(); + } + } catch (Exception e) { + // Ignore + } + return "unknown"; + } +} diff --git a/src/test/java/org/opengrok/docker/README.md b/src/test/java/org/opengrok/docker/README.md new file mode 100644 index 00000000000..096696e4b27 --- /dev/null +++ b/src/test/java/org/opengrok/docker/README.md @@ -0,0 +1,31 @@ +# Docker Image Integration Tests + +Integration tests for the OpenGrok Docker image using Testcontainers. + +## Running + +```bash +# Run integration tests +mvn verify + +# Skip if Docker not available +mvn verify -DskipITs=true +``` + +## What's tested + +- Container startup with volume mounts +- File ownership (appuser:appgroup) - catches chown bugs +- Web interface on port 8080 +- REST API on port 5000 +- Indexer operation +- Non-root process execution +- Startup error detection + +## Notes + +- Requires Docker to be running +- Takes about 3-5 minutes +- Tests run automatically in CI + +Related: issue #4912