From 5d144533201774632c3e5893fb70e70457283e50 Mon Sep 17 00:00:00 2001 From: Patrick Hobusch Date: Tue, 14 Jul 2026 13:52:32 +0200 Subject: [PATCH] Run the product setup wizards from the plugin JARs The plugin JARs now double as setup tools: java -jar drives the product's setup wizard over HTTP so a freshly installed instance can be set up unattended, e.g. from a deployment hook job running next to the application. The CLIs are built on the JDK HTTP client alone, so the plain plugin JAR runs without any further dependencies, and they are configured through BOOTSTRAPI_SETUP_* environment variables. The Crowd wizard is driven completely, including the database step. For Jira and Confluence the database connection must already be configured, as the official container images do; their wizards are driven from the application properties and data step onwards. All CLIs detect an already set up instance and exit successfully, resume at the current wizard step where the wizard supports it, and verify the resulting application state at the end: the wizards answer invalid input with an error page instead of an error status, so only the resulting state proves the setup went through. The Crowd wizard flow is based on the crowd-init script of the ldap-crowd-adapter project (Apache License 2.0, ASERVO Software GmbH). Every CLI is unit tested against a stubbed wizard, and integration tests set up pristine instances from the official container images backed by a PostgreSQL container. The integration tests are excluded from normal builds and run explicitly, see CONTRIBUTING.md. --- .github/workflows/ci.yaml | 36 +++ CONTRIBUTING.md | 8 + README.md | 6 + .../bootstrapi/commons/cli/SetupEnv.java | 35 +++ .../commons/cli/SetupException.java | 17 ++ .../commons/cli/SetupHttpSession.java | 247 ++++++++++++++++++ .../commons/cli/SetupHttpSessionTest.java | 38 +++ confluence/pom.xml | 20 ++ .../bootstrapi/confluence/cli/SetupCli.java | 160 ++++++++++++ .../bootstrapi/confluence/cli/SetupCliIT.java | 89 +++++++ .../confluence/cli/SetupCliTest.java | 151 +++++++++++ crowd/pom.xml | 20 ++ .../bootstrapi/crowd/cli/SetupCli.java | 215 +++++++++++++++ .../bootstrapi/crowd/cli/SetupCliIT.java | 98 +++++++ .../bootstrapi/crowd/cli/SetupCliTest.java | 156 +++++++++++ jira/pom.xml | 20 ++ .../bootstrapi/jira/cli/SetupCli.java | 168 ++++++++++++ .../bootstrapi/jira/cli/SetupCliIT.java | 93 +++++++ .../bootstrapi/jira/cli/SetupCliTest.java | 180 +++++++++++++ pom.xml | 20 ++ 20 files changed, 1777 insertions(+) create mode 100644 commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java create mode 100644 commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java create mode 100644 commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java create mode 100644 commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java create mode 100644 confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java create mode 100644 confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java create mode 100644 confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java create mode 100644 crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java create mode 100644 crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java create mode 100644 crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java create mode 100644 jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java create mode 100644 jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java create mode 100644 jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a1fd9079..dc2d0bb4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -97,6 +97,42 @@ jobs: - name: Run integration tests run: mvn -B -pl ${{ matrix.product }} -am verify + setup-tests: + needs: unit-tests + + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + product: + - confluence + - crowd + - jira + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: 21 + distribution: temurin + cache: 'maven' + + # The Jira and Confluence tests skip without a license secret; the Crowd + # test then still verifies the wizard mechanics against a real instance. + # Secret names are case-insensitive, so the product name resolves the + # SETUP_IT_LICENSE_ secret directly. + - name: Run setup CLI integration tests + env: + BOOTSTRAPI_SETUP_IT: true + BOOTSTRAPI_SETUP_IT_LICENSE: ${{ secrets[format('SETUP_IT_LICENSE_{0}', matrix.product)] }} + run: mvn -B -pl ${{ matrix.product }} -am test -Dtest=SetupCliIT -Dsurefire.failIfNoSpecifiedTests=false + apcc: needs: build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64bbfd56..79e9ac44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,14 @@ Models are Lombok beans annotated with `@Data`: - Expected status codes are asserted as plain integer literals (`assertEquals(200, ...)`). - Test fixtures reuse the models' `EXAMPLE_*` constants and derive keys via `FieldNames` instead of repeating literals. +### Setup CLI integration tests + +The setup CLIs (`java -jar .jar` drives the product setup wizard) have integration tests that boot the official product container images together with a PostgreSQL container. They are excluded from normal builds and run explicitly: + + BOOTSTRAPI_SETUP_IT=true BOOTSTRAPI_SETUP_IT_LICENSE= mvn -pl test -Dtest=SetupCliIT + +The Jira and Confluence tests require a license and skip without one (the public Data Center timebomb licenses work). The Crowd test runs without a license by verifying that the CLI fails loud when the wizard rejects input; with a license it verifies the complete setup. + ## Pull requests - Keep pull requests focused on one topic. diff --git a/README.md b/README.md index 1c2054f7..872b5ac5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ The startup configuration is cluster-safe: only one node applies the file at a t A configuration that cannot be read, parsed or fully applied stops the application, so an instance never comes up with a configuration it could not reach - in a rolling deployment this blocks the rollout instead of hiding the failure. Since only successful applies are recorded, the configuration is retried when the instance is started again. +## Instance setup + +The plugin JARs double as setup tools: running `java -jar bootstrapi--plugin.jar` drives the product setup wizard of a freshly installed instance over HTTP, configured through `BOOTSTRAPI_SETUP_*` environment variables (base URL, license, database connection, administrator account). Together with the startup configuration this makes a complete instance bootstrap declarative: a deployment hook runs the setup from the same artifact that is installed as the plugin, and the `bootstrapi.yaml` applies everything else the moment the setup completes. + +The Crowd and Jira wizards are driven completely, including the database step. For Confluence the database connection and license must already be configured, e.g. through the `ATL_*` environment variables of the official container images. + ## Installation Download the plugin for your product from the [releases](https://github.com/deftdevs/bootstrapi/releases) and upload it in the product's administration under *Manage apps* → *Upload app*. The endpoints require a user with system administrator permissions. diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java new file mode 100644 index 00000000..b4e554fe --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupEnv.java @@ -0,0 +1,35 @@ +package com.deftdevs.bootstrapi.commons.cli; + +/** + * Environment access for the setup CLIs. System properties take precedence + * over environment variables so tests (and ad hoc runs) can override values + * without touching the process environment. + */ +public final class SetupEnv { + + public static String require( + final String name) { + + final String value = get(name, null); + if (value == null || value.isBlank()) { + throw new SetupException("Required environment variable '" + name + "' is not set"); + } + return value; + } + + public static String get( + final String name, + final String defaultValue) { + + final String property = System.getProperty(name); + if (property != null) { + return property; + } + + final String env = System.getenv(name); + return env != null ? env : defaultValue; + } + + private SetupEnv() { + } +} diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java new file mode 100644 index 00000000..c7dd489f --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupException.java @@ -0,0 +1,17 @@ +package com.deftdevs.bootstrapi.commons.cli; + +public class SetupException extends RuntimeException { + + public SetupException( + final String message) { + + super(message); + } + + public SetupException( + final String message, + final Throwable cause) { + + super(message, cause); + } +} diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java new file mode 100644 index 00000000..6abc5883 --- /dev/null +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java @@ -0,0 +1,247 @@ +package com.deftdevs.bootstrapi.commons.cli; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * A cookie-aware HTTP session for driving the product setup wizards from the + * command line. Deliberately built on the JDK HTTP client only: the setup + * CLIs run the plain plugin JAR outside the application, where none of the + * provided dependencies are available. + */ +public class SetupHttpSession { + + // single wizard steps can take very long, e.g. Jira initialising the database + private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(10); + + static { + // the JDK HTTP client follows at most five redirects by default, + // but e.g. the Confluence setup redirect chain is longer + if (System.getProperty("jdk.httpclient.redirects.retrylimit") == null) { + System.setProperty("jdk.httpclient.redirects.retrylimit", "10"); + } + } + + private final URI baseUri; + private final HttpClient followingClient; + private final HttpClient plainClient; + + public SetupHttpSession( + final String baseUrl) { + + this.baseUri = URI.create(baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl); + final CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + this.followingClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + this.plainClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * Polls the given path until it answers with a success status. + */ + public void waitUntilAvailable( + final String path, + final Duration timeout, + final Duration pollInterval) { + + final long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + try { + final HttpResponse response = send(followingClient, getRequest(path)); + if (response.statusCode() < 300) { + return; + } + } catch (SetupException e) { + // not reachable yet + } + + if (System.nanoTime() > deadline) { + throw new SetupException("Timed out waiting for " + baseUri + path + " to become available"); + } + + System.out.println("Waiting for " + baseUri + " to become available..."); + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while waiting for " + baseUri, e); + } + } + } + + /** + * Polls the given path until its body contains one of the expected + * markers, e.g. the application state in the status endpoint of Jira and + * Confluence, which answers long before the application is ready. + */ + public String waitForAnyState( + final String path, + final Duration timeout, + final Duration pollInterval, + final String... states) { + + final long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + try { + final String body = get(path); + for (final String state : states) { + if (body.contains(state)) { + return body; + } + } + } catch (SetupException e) { + // not reachable yet + } + + if (System.nanoTime() > deadline) { + throw new SetupException("Timed out waiting for " + baseUri + path + + " to report one of " + String.join(", ", states)); + } + + System.out.println("Waiting for " + baseUri + " to become ready..."); + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while waiting for " + baseUri, e); + } + } + } + + /** + * GET following redirects; fails on a non-success status. + */ + public String get( + final String path) { + + final HttpResponse response = send(followingClient, getRequest(path)); + requireSuccess("GET", path, response); + return response.body(); + } + + /** + * GET without following redirects, returning the redirect target if any. + */ + public Optional getLocation( + final String path) { + + final HttpResponse response = send(plainClient, getRequest(path)); + return response.headers().firstValue("location"); + } + + /** + * POST a form following redirects; fails on a non-success status. + */ + public String postForm( + final String path, + final Map form) { + + final String body = form.entrySet().stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .collect(Collectors.joining("&")); + final HttpRequest request = HttpRequest.newBuilder(resolve(path)) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + final HttpResponse response = send(followingClient, request); + requireSuccess("POST", path, response); + return response.body(); + } + + /** + * Extracts the value of a named form input from an HTML page, e.g. the + * {@code atl_token} XSRF token of a setup wizard step. + */ + public static String parseFormInput( + final String html, + final String name) { + + // attributes may be spread over multiple lines + final String stripped = html.replaceAll("\\s", ""); + final String marker = "name=\"" + name + "\""; + + final int nameIndex = stripped.indexOf(marker); + if (nameIndex < 0) { + throw new SetupException("No form input '" + name + "' found in the response page"); + } + + final int tagStart = Math.max(stripped.lastIndexOf('<', nameIndex), 0); + final int tagEnd = stripped.indexOf('>', nameIndex); + final String tag = stripped.substring(tagStart, tagEnd > 0 ? tagEnd : stripped.length()); + + final Matcher matcher = Pattern.compile("value=\"([^\"]*)\"").matcher(tag); + if (!matcher.find()) { + throw new SetupException("No value found for form input '" + name + "'"); + } + return matcher.group(1); + } + + private HttpRequest getRequest( + final String path) { + + return HttpRequest.newBuilder(resolve(path)) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + } + + private URI resolve( + final String path) { + + return URI.create(baseUri + path); + } + + private HttpResponse send( + final HttpClient client, + final HttpRequest request) { + + try { + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new SetupException("Request to " + request.uri() + " failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SetupException("Request to " + request.uri() + " was interrupted", e); + } + } + + private static void requireSuccess( + final String method, + final String path, + final HttpResponse response) { + + if (response.statusCode() >= 300) { + final String body = response.body() != null ? response.body() : ""; + throw new SetupException(method + " " + path + " failed with status " + response.statusCode() + + (body.isBlank() ? "" : ": " + body.substring(0, Math.min(body.length(), 500)))); + } + } + + private static String encode( + final String value) { + + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java new file mode 100644 index 00000000..d46ce5e2 --- /dev/null +++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSessionTest.java @@ -0,0 +1,38 @@ +package com.deftdevs.bootstrapi.commons.cli; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SetupHttpSessionTest { + + @Test + void testParseFormInput() { + final String html = "
"; + assertEquals("TOKEN123", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputWithAttributeBetweenNameAndValue() { + final String html = ""; + assertEquals("TOKEN456", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputAcrossMultipleLines() { + final String html = ""; + assertEquals("BTKU-QFB7-2EPG-9S8L", SetupHttpSession.parseFormInput(html, "sid")); + } + + @Test + void testParseFormInputWithValueBeforeName() { + final String html = ""; + assertEquals("TOKEN789", SetupHttpSession.parseFormInput(html, "atl_token")); + } + + @Test + void testParseFormInputMissingField() { + assertThrows(SetupException.class, () -> SetupHttpSession.parseFormInput("", "atl_token")); + } +} diff --git a/confluence/pom.xml b/confluence/pom.xml index fbf783cd..83073d70 100644 --- a/confluence/pom.xml +++ b/confluence/pom.xml @@ -331,6 +331,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -364,6 +382,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.confluence.cli.SetupCli *;resolution:="optional" diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java new file mode 100644 index 00000000..e23cd901 --- /dev/null +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/cli/SetupCli.java @@ -0,0 +1,160 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Drives the Confluence setup wizard over HTTP so a fresh instance can be + * set up unattended, e.g. from a deployment hook job. Run it directly from + * the plugin JAR: {@code java -jar bootstrapi-confluence-plugin.jar}. + *

+ * The license and database connection must already be configured (a + * pre-seeded {@code confluence.cfg.xml}); the wizard then starts at the + * data step. + */ +public class SetupCli { + + private final SetupHttpSession session; + + // the setup steps in wizard order with the redirect markers used to resume + private final List steps = List.of( + new Step("/setup/setupcluster-start.action", this::setupCluster), + new Step("/setup/setupdata-start.action", this::setupData), + new Step("/setup/setupusermanagementchoice-start.action", this::setupUserManagement), + new Step("/setup/setupadministrator-start.action", this::setupAdministrator), + new Step("/setup/finishsetup-start.action", this::finishSetup)); + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + + // the status endpoint answers with STARTING long before the application is + // ready; FIRST_RUN means the setup wizard is pending + final String status = session.waitForAnyState("/status", timeout, pollInterval, "RUNNING", "FIRST_RUN"); + if (status.contains("RUNNING")) { + System.out.println("Confluence is already set up, nothing to do."); + return 0; + } + + // the initial page request creates the session cookie + session.get("/"); + + final String stepLocation = session.getLocation("/bootstrap/selectsetupstep.action") + .orElseThrow(() -> new SetupException("The setup step selection did not redirect to a setup step")); + + int stepIndex = -1; + for (int i = 0; i < steps.size(); i++) { + if (stepLocation.contains(steps.get(i).marker)) { + stepIndex = i; + break; + } + } + if (stepIndex < 0) { + throw new SetupException("Unknown setup step: " + stepLocation); + } + + for (int i = stepIndex; i < steps.size(); i++) { + steps.get(i).action.run(); + } + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + if (!session.get("/status").contains("RUNNING")) { + throw new SetupException("The setup did not complete; the application does not report state RUNNING" + + " (most likely a submitted value was rejected)"); + } + + System.out.println("Setting up Confluence done."); + return 0; + } + + private void setupCluster() { + System.out.println("Skipping the clustering setup..."); + + // a cluster is configured through confluence.cfg.xml, not through the wizard + + final Map form = new LinkedHashMap<>(); + form.put("newCluster", "skipCluster"); + form.put("atl_token", token()); + session.postForm("/setup/setupcluster.action", form); + } + + private void setupData() { + System.out.println("Setting up data..."); + final Map form = new LinkedHashMap<>(); + form.put("dbchoiceSelect", "Empty Site"); + form.put("contentChoice", "blank"); + form.put("atl_token", token()); + session.postForm("/setup/setupdata.action", form); + } + + private void setupUserManagement() { + System.out.println("Setting up user management..."); + final Map form = new LinkedHashMap<>(); + form.put("userManagementChoice", "internal"); + form.put("internal", "Manage users and groups within Confluence"); + form.put("atl_token", token()); + session.postForm("/setup/setupusermanagementchoice.action", form); + } + + private void setupAdministrator() { + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("username", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("fullName", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("password", password); + form.put("confirm", password); + form.put("setup-next-button", "Next"); + form.put("atl_token", token()); + session.postForm("/setup/setupadministrator.action", form); + } + + private void finishSetup() { + System.out.println("Finishing the setup..."); + session.get("/setup/finishsetup.action"); + } + + private String token() { + return SetupHttpSession.parseFormInput(session.get("/"), "atl_token"); + } + + private static class Step { + + private final String marker; + private final Runnable action; + + private Step( + final String marker, + final Runnable action) { + + this.marker = marker; + this.action = action; + } + } +} diff --git a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java new file mode 100644 index 00000000..29f27280 --- /dev/null +++ b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliIT.java @@ -0,0 +1,89 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Confluence from the official container image by driving + * the setup wizard, backed by a PostgreSQL container. The image templates the + * database connection and the license into {@code confluence.cfg.xml}, so the + * wizard starts at the data step. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} and a license in + * {@code BOOTSTRAPI_SETUP_IT_LICENSE} (the public Confluence Data Center + * timebomb license works). + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("confluence") + .withUsername("confluence") + .withPassword("confluence"); + + @Container + private static final GenericContainer CONFLUENCE = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/confluence:10.2.14")) + .withNetwork(NETWORK) + .withExposedPorts(8090) + .withEnv("ATL_DB_TYPE", "postgresql") + .withEnv("ATL_JDBC_URL", "jdbc:postgresql://postgres:5432/confluence") + .withEnv("ATL_JDBC_USER", "confluence") + .withEnv("ATL_JDBC_PASSWORD", "confluence") + .withEnv("ATL_LICENSE_KEY", SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", "")) + .withEnv("JVM_MINIMUM_MEMORY", "1g") + .withEnv("JVM_MAXIMUM_MEMORY", "2g"); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + assumeTrue(SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null) != null, + "BOOTSTRAPI_SETUP_IT_LICENSE is not set"); + + final String baseUrl = "http://" + CONFLUENCE.getHost() + ":" + CONFLUENCE.getMappedPort(8090); + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "900"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assertEquals(0, new SetupCli(session).run()); + + assertTrue(session.get("/status").contains("RUNNING")); + + // a second run is a no-op + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java new file mode 100644 index 00000000..e1c33a8d --- /dev/null +++ b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/cli/SetupCliTest.java @@ -0,0 +1,151 @@ +package com.deftdevs.bootstrapi.confluence.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + private final AtomicBoolean finishCalled = new AtomicBoolean(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/confluence"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRun() { + stubWizard("/setup/setupcluster-start.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals("skipCluster", capturedForms.get("/confluence/setup/setupcluster.action").get("newCluster")); + assertEquals("Empty Site", capturedForms.get("/confluence/setup/setupdata.action").get("dbchoiceSelect")); + assertEquals("internal", capturedForms.get("/confluence/setup/setupusermanagementchoice.action").get("userManagementChoice")); + assertEquals("admin", capturedForms.get("/confluence/setup/setupadministrator.action").get("username")); + assertEquals("secret", capturedForms.get("/confluence/setup/setupadministrator.action").get("confirm")); + assertEquals("BASE-TOKEN", capturedForms.get("/confluence/setup/setupadministrator.action").get("atl_token")); + assertTrue(finishCalled.get()); + } + + @Test + void testResumesFromAdministratorStep() { + stubWizard("/setup/setupadministrator-start.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertFalse(capturedForms.containsKey("/confluence/setup/setupdata.action")); + assertFalse(capturedForms.containsKey("/confluence/setup/setupusermanagementchoice.action")); + assertTrue(capturedForms.containsKey("/confluence/setup/setupadministrator.action")); + assertTrue(finishCalled.get()); + } + + @Test + void testAlreadySetUpDoesNothing() { + server.createContext("/confluence", exchange -> { + if (exchange.getRequestURI().getPath().equals("/confluence/status")) { + respond(exchange, 200, "{\"state\":\"RUNNING\"}"); + } else { + respond(exchange, 404, "unexpected"); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + assertFalse(finishCalled.get()); + } + + private void stubWizard( + final String currentStep) { + + server.createContext("/confluence", exchange -> { + final String path = exchange.getRequestURI().getPath(); + if (path.equals("/confluence/status")) { + // finishing the setup flips the application state to RUNNING + respond(exchange, 200, finishCalled.get() + ? "{\"state\":\"RUNNING\"}" + : "{\"state\":\"FIRST_RUN\"}"); + } else if (path.equals("/confluence/bootstrap/selectsetupstep.action")) { + exchange.getResponseHeaders().add("Location", "/confluence" + currentStep); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + } else if (path.equals("/confluence/setup/finishsetup.action")) { + finishCalled.set(true); + respond(exchange, 200, "done"); + } else if (path.startsWith("/confluence/setup/")) { + capture(exchange, path); + respond(exchange, 200, "ok"); + } else { + // the base page carries the session cookie and the XSRF token + respond(exchange, 200, ""); + } + }); + } + + private void capture( + final HttpExchange exchange, + final String path) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + capturedForms.put(path, form); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } +} diff --git a/crowd/pom.xml b/crowd/pom.xml index ac318ce5..e8cb863d 100644 --- a/crowd/pom.xml +++ b/crowd/pom.xml @@ -292,6 +292,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -319,6 +337,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.crowd.cli.SetupCli *;resolution:="optional" diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java new file mode 100644 index 00000000..d0bfd582 --- /dev/null +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/cli/SetupCli.java @@ -0,0 +1,215 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Drives the Crowd setup wizard over HTTP so a fresh instance can be set up + * unattended, e.g. from a deployment hook job. Run it directly from the + * plugin JAR: {@code java -jar bootstrapi-crowd-plugin.jar}. + *

+ * The wizard flow is based on the crowd-init script of the ldap-crowd-adapter + * project (Apache License 2.0, ASERVO Software GmbH): + * https://github.com/aservo/ldap-crowd-adapter + */ +public class SetupCli { + + private final SetupHttpSession session; + + // the setup steps in wizard order with the redirect markers used to resume + private final List steps = List.of( + new Step("/console/setup/setuplicense.action", this::setupLicense), + new Step("/console/setup/installtype.action", this::setupInstallType), + new Step("/console/setup/setupdatabase.action", this::setupDatabase), + new Step("/console/setup/setupoptions.action", this::setupOptions), + new Step("/console/setup/directoryinternalsetup.action", this::setupInternalDirectory), + new Step("/console/setup/defaultadministrator.action", this::setupAdministrator), + new Step("/console/setup/integration.action", this::setupIntegration)); + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + session.waitUntilAvailable("/", timeout, pollInterval); + + final Optional loginLocation = session.getLocation("/console/login.action"); + if (loginLocation.isEmpty() || !loginLocation.get().contains("/console/setup/")) { + System.out.println("Crowd is already set up, nothing to do."); + return 0; + } + + final String stepLocation = session.getLocation("/console/setup/selectsetupstep.action") + .orElseThrow(() -> new SetupException("The setup step selection did not redirect to a setup step")); + + int stepIndex = -1; + for (int i = 0; i < steps.size(); i++) { + if (stepLocation.contains(steps.get(i).marker)) { + stepIndex = i; + break; + } + } + if (stepIndex < 0) { + throw new SetupException("Unknown setup step: " + stepLocation); + } + + for (int i = stepIndex; i < steps.size(); i++) { + steps.get(i).action.run(); + } + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + final Optional verification = session.getLocation("/console/login.action"); + if (verification.isPresent() && verification.get().contains("/console/setup/")) { + throw new SetupException("The setup did not complete; the wizard is still at " + verification.get() + + " (most likely a submitted value was rejected, e.g. an invalid license)"); + } + + System.out.println("Setting up Crowd done."); + return 0; + } + + private void setupLicense() { + System.out.println("Setting up license..."); + final String page = session.get("/console/setup/setuplicense.action"); + final String serverId = SetupEnv.get("BOOTSTRAPI_SETUP_SERVER_ID", + SetupHttpSession.parseFormInput(page, "sid")); + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", SetupHttpSession.parseFormInput(page, "atl_token")); + form.put("sid", serverId); + form.put("key", SetupEnv.require("BOOTSTRAPI_SETUP_LICENSE")); + session.postForm("/console/setup/setuplicense!update.action", form); + } + + private void setupInstallType() { + System.out.println("Setting up install type..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/installtype.action")); + form.put("installOption", "install.new"); + session.postForm("/console/setup/installtype!update.action", form); + } + + private void setupDatabase() { + System.out.println("Setting up database..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/setupdatabase.action")); + + if ("embedded".equals(SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_OPTION", "jdbc"))) { + form.put("databaseOption", "db.embedded"); + } else { + form.put("databaseOption", "db.jdbc"); + form.put("jdbcDatabaseType", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_TYPE")); + form.put("jdbcDriverClassName", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_DRIVER")); + form.put("jdbcUrl", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_URL")); + form.put("jdbcUsername", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_USERNAME")); + form.put("jdbcPassword", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_PASSWORD")); + form.put("jdbcHibernateDialect", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_DIALECT")); + } + session.postForm("/console/setup/setupdatabase!update.action", form); + } + + private void setupOptions() { + System.out.println("Setting up options..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/setupoptions.action")); + form.put("title", SetupEnv.require("BOOTSTRAPI_SETUP_TITLE")); + form.put("sessionTime", SetupEnv.get("BOOTSTRAPI_SETUP_SESSION_TIME", "30")); + form.put("baseURL", SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL")); + session.postForm("/console/setup/setupoptions!update.action", form); + } + + private void setupInternalDirectory() { + System.out.println("Setting up internal directory..."); + + // the password policy is deliberately left open so setting up the administrator + // cannot fail; policies can be applied afterwards through the REST API + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/directoryinternalsetup.action")); + form.put("name", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_NAME", "Internal directory")); + form.put("description", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_DESCRIPTION", "")); + form.put("passwordRegex", ""); + form.put("passwordComplexityMessage", ""); + form.put("passwordMaxAttempts", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_MAX_ATTEMPTS", "0")); + form.put("passwordHistoryCount", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_HISTORY_COUNT", "0")); + form.put("passwordMaxChangeTime", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_MAX_CHANGE_TIME", "0")); + form.put("userEncryptionMethod", SetupEnv.get("BOOTSTRAPI_SETUP_DIRECTORY_PASSWORD_ENCRYPTION_METHOD", "atlassian-security")); + session.postForm("/console/setup/directoryinternalsetup!update.action", form); + } + + private void setupAdministrator() { + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", stepToken("/console/setup/defaultadministrator.action")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("name", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("firstname", SetupEnv.get("BOOTSTRAPI_SETUP_ADMIN_FIRST_NAME", "Admin")); + form.put("lastname", SetupEnv.get("BOOTSTRAPI_SETUP_ADMIN_LAST_NAME", "Admin")); + form.put("password", password); + form.put("passwordConfirm", password); + session.postForm("/console/setup/defaultadministrator!update.action", form); + } + + private void setupIntegration() { + System.out.println("Setting up integration..."); + + // the only available integration is the OpenID server, which stays disabled; + // newer Crowd versions no longer have this step and finish after the administrator + final String token; + try { + token = stepToken("/console/setup/integration.action"); + } catch (SetupException e) { + System.out.println("The integration step is not present, skipping."); + return; + } + + final Map form = new LinkedHashMap<>(); + form.put("atl_token", token); + session.postForm("/console/setup/integration!update.action", form); + } + + private String stepToken( + final String stepPath) { + + return SetupHttpSession.parseFormInput(session.get(stepPath), "atl_token"); + } + + private static class Step { + + private final String marker; + private final Runnable action; + + private Step( + final String marker, + final Runnable action) { + + this.marker = marker; + this.action = action; + } + } +} diff --git a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java new file mode 100644 index 00000000..75168637 --- /dev/null +++ b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliIT.java @@ -0,0 +1,98 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Crowd from the official container image by driving the + * complete setup wizard, backed by a PostgreSQL container. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} because it downloads and boots real + * product containers. + *

+ * Without a license in {@code BOOTSTRAPI_SETUP_IT_LICENSE} only the wizard + * mechanics are verified: the CLI must walk the wizard up to the license + * validation and fail loud, not silently. + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("crowd") + .withUsername("crowd") + .withPassword("crowd"); + + @Container + private static final GenericContainer CROWD = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/crowd:7.2.1")) + .withNetwork(NETWORK) + .withExposedPorts(8095); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + final String license = SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null); + final String baseUrl = "http://" + CROWD.getHost() + ":" + CROWD.getMappedPort(8095) + "/crowd"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "600"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", license != null ? license : "INVALID-LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Setup IT"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_TYPE", "PostgreSQL"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_DRIVER", "org.postgresql.Driver"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_URL", "jdbc:postgresql://postgres:5432/crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "crowd"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_DIALECT", "org.hibernate.dialect.PostgreSQLDialect"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + + if (license == null) { + // without a real license the wizard silently stays behind (it answers + // invalid input with an error page, not an error status), so the only + // acceptable outcome is that the CLI fails loud at whatever step the + // rejection surfaces + assertThrows(SetupException.class, () -> new SetupCli(new SetupHttpSession(baseUrl)).run()); + return; + } + + assertEquals(0, new SetupCli(new SetupHttpSession(baseUrl)).run()); + + // the wizard is gone and a second run is a no-op + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assumeTrue(session.getLocation("/console/login.action").isEmpty()); + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java new file mode 100644 index 00000000..8967ab24 --- /dev/null +++ b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/cli/SetupCliTest.java @@ -0,0 +1,156 @@ +package com.deftdevs.bootstrapi.crowd.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/crowd"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", "LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_OPTION", "embedded"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Test Crowd"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRun() { + stubWizard("/crowd/console/setup/setuplicense.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals("LICENSE-KEY", capturedForms.get("/crowd/console/setup/setuplicense!update.action").get("key")); + assertEquals("SERVER-ID", capturedForms.get("/crowd/console/setup/setuplicense!update.action").get("sid")); + assertEquals("install.new", capturedForms.get("/crowd/console/setup/installtype!update.action").get("installOption")); + assertEquals("db.embedded", capturedForms.get("/crowd/console/setup/setupdatabase!update.action").get("databaseOption")); + assertEquals("Test Crowd", capturedForms.get("/crowd/console/setup/setupoptions!update.action").get("title")); + assertEquals("Internal directory", capturedForms.get("/crowd/console/setup/directoryinternalsetup!update.action").get("name")); + assertEquals("admin", capturedForms.get("/crowd/console/setup/defaultadministrator!update.action").get("name")); + assertEquals("secret", capturedForms.get("/crowd/console/setup/defaultadministrator!update.action").get("passwordConfirm")); + assertEquals("STEP-TOKEN", capturedForms.get("/crowd/console/setup/integration!update.action").get("atl_token")); + } + + @Test + void testResumesFromLaterStep() { + stubWizard("/crowd/console/setup/defaultadministrator.action"); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertFalse(capturedForms.containsKey("/crowd/console/setup/setuplicense!update.action")); + assertFalse(capturedForms.containsKey("/crowd/console/setup/setupdatabase!update.action")); + assertTrue(capturedForms.containsKey("/crowd/console/setup/defaultadministrator!update.action")); + assertTrue(capturedForms.containsKey("/crowd/console/setup/integration!update.action")); + } + + @Test + void testAlreadySetUpDoesNothing() throws IOException { + // a set up instance serves the login page without redirecting to the wizard + server.createContext("/crowd", exchange -> respond(exchange, 200, "login")); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + } + + private void stubWizard( + final String currentStep) { + + final java.util.concurrent.atomic.AtomicBoolean wizardComplete = new java.util.concurrent.atomic.AtomicBoolean(); + server.createContext("/crowd", exchange -> { + final String path = exchange.getRequestURI().getPath(); + if (path.equals("/crowd/console/login.action") || path.equals("/crowd/console/setup/selectsetupstep.action")) { + // once the wizard has completed, the login page no longer redirects to it + if (wizardComplete.get()) { + respond(exchange, 200, "login"); + } else { + redirect(exchange, currentStep); + } + } else if (path.endsWith("!update.action")) { + capturedForms.put(path, parseForm(exchange)); + if (path.endsWith("/integration!update.action")) { + wizardComplete.set(true); + } + respond(exchange, 200, "ok"); + } else if (path.startsWith("/crowd/console/setup/")) { + respond(exchange, 200, "" + + ""); + } else { + respond(exchange, 200, "base"); + } + }); + } + + private static void redirect( + final HttpExchange exchange, + final String location) throws IOException { + + exchange.getResponseHeaders().add("Location", location); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private static Map parseForm( + final HttpExchange exchange) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + return form; + } +} diff --git a/jira/pom.xml b/jira/pom.xml index 1d849d61..80eb7bf3 100644 --- a/jira/pom.xml +++ b/jira/pom.xml @@ -259,6 +259,24 @@ test + + org.postgresql + postgresql + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-postgresql + test + + org.glassfish.jersey.core jersey-common @@ -297,6 +315,8 @@ ${atlassian.plugin.key} + + com.deftdevs.bootstrapi.jira.cli.SetupCli *;resolution:="optional" diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java new file mode 100644 index 00000000..a8bdd9e4 --- /dev/null +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/cli/SetupCli.java @@ -0,0 +1,168 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupException; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Drives the Jira setup wizard over HTTP so a fresh instance can be set up + * unattended, e.g. from a deployment hook job. Run it directly from the + * plugin JAR: {@code java -jar bootstrapi-jira-plugin.jar}. + *

+ * On an empty database the wizard starts with the database step, which is + * driven from the {@code BOOTSTRAPI_SETUP_DATABASE_*} variables; afterwards + * (or when the database is already initialised) the wizard continues at the + * application properties step. + */ +public class SetupCli { + + private final SetupHttpSession session; + + public static void main( + final String[] args) { + + try { + System.exit(new SetupCli(new SetupHttpSession(SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL"))).run()); + } catch (SetupException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } + } + + SetupCli( + final SetupHttpSession session) { + + this.session = session; + } + + int run() { + final Duration timeout = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "300"))); + final Duration pollInterval = Duration.ofSeconds(Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_POLL_SECONDS", "5"))); + + // the status endpoint answers with STARTING long before the application is + // ready; FIRST_RUN means the setup wizard is pending + final String status = session.waitForAnyState("/status", timeout, pollInterval, "RUNNING", "FIRST_RUN"); + if (status.contains("RUNNING")) { + System.out.println("Jira is already set up, nothing to do."); + return 0; + } + + // the initial page request creates the session and shows the current setup step + String page = session.get("/"); + if (page.contains("SetupDatabase.jspa")) { + setupDatabase(page); + page = session.get("/"); + } + + String token = SetupHttpSession.parseFormInput(page, "atl_token"); + token = setupApplicationProperties(token); + token = setupLicense(token); + token = setupAdministrator(token); + setupMailNotifications(token); + + // the wizard answers invalid input with a 200 error page and stays on the + // current step, so only the resulting state proves the setup went through + if (!session.get("/status").contains("RUNNING")) { + throw new SetupException("The setup did not complete; the application does not report state RUNNING" + + " (most likely a submitted value was rejected, e.g. an invalid license)"); + } + + System.out.println("Setting up Jira done."); + return 0; + } + + private void setupDatabase( + final String page) { + + System.out.println("Setting up the database (this initialises the database and can take a while)..."); + final Map form = new LinkedHashMap<>(); + form.put("atl_token", SetupHttpSession.parseFormInput(page, "atl_token")); + form.put("databaseOption", "external"); + form.put("databaseType", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_TYPE", "postgres72")); + form.put("jdbcHostname", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME")); + form.put("jdbcPort", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_PORT", "5432")); + form.put("jdbcDatabase", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_NAME")); + form.put("jdbcUsername", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_USERNAME")); + form.put("jdbcPassword", SetupEnv.require("BOOTSTRAPI_SETUP_DATABASE_PASSWORD")); + form.put("schemaName", SetupEnv.get("BOOTSTRAPI_SETUP_DATABASE_SCHEMA", "public")); + form.put("testingConnection", "false"); + session.postForm("/secure/SetupDatabase.jspa", form); + } + + private String setupApplicationProperties( + final String token) { + + System.out.println("Setting up application properties..."); + final Map form = new LinkedHashMap<>(); + form.put("baseURL", SetupEnv.require("BOOTSTRAPI_SETUP_BASE_URL")); + form.put("title", SetupEnv.require("BOOTSTRAPI_SETUP_TITLE")); + form.put("mode", SetupEnv.get("BOOTSTRAPI_SETUP_MODE", "private")); + form.put("nextStep", "true"); + form.put("atl_token", token); + return SetupHttpSession.parseFormInput( + session.postForm("/secure/SetupApplicationProperties.jspa", form), "atl_token"); + } + + private String setupLicense( + final String token) { + + System.out.println("Setting up license (this continues initialising the database and can take a while)..."); + final int maxRetries = Integer.parseInt(SetupEnv.get("BOOTSTRAPI_SETUP_MAX_RETRIES", "3")); + final long retryDelayMillis = Long.parseLong(SetupEnv.get("BOOTSTRAPI_SETUP_RETRY_SECONDS", "10")) * 1000; + + final Map form = new LinkedHashMap<>(); + form.put("setupLicenseKey", SetupEnv.require("BOOTSTRAPI_SETUP_LICENSE")); + form.put("atl_token", token); + + // freshly started instances occasionally answer with an error; retry a few times + for (int attempt = 0; ; attempt++) { + try { + return SetupHttpSession.parseFormInput(session.postForm("/secure/SetupLicense.jspa", form), "atl_token"); + } catch (SetupException e) { + if (attempt >= maxRetries) { + throw e; + } + System.out.println("Setting up the license failed, retrying: " + e.getMessage()); + try { + Thread.sleep(retryDelayMillis); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new SetupException("Interrupted while retrying the license setup", ie); + } + } + } + } + + private String setupAdministrator( + final String token) { + + System.out.println("Setting up administrator..."); + final String password = SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_PASSWORD"); + + final Map form = new LinkedHashMap<>(); + form.put("fullname", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME")); + form.put("email", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_EMAIL")); + form.put("username", SetupEnv.require("BOOTSTRAPI_SETUP_ADMIN_USERNAME")); + form.put("password", password); + form.put("confirm", password); + form.put("atl_token", token); + return SetupHttpSession.parseFormInput(session.postForm("/secure/SetupAdminAccount.jspa", form), "atl_token"); + } + + private void setupMailNotifications( + final String token) { + + System.out.println("Finishing the setup (skipping mail notifications)..."); + + // the mail server is skipped here; it can be configured through the REST API + + final Map form = new LinkedHashMap<>(); + form.put("noemail", "true"); + form.put("atl_token", token); + session.postForm("/secure/SetupMailNotifications.jspa", form); + } +} diff --git a/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java new file mode 100644 index 00000000..a4cdc65f --- /dev/null +++ b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliIT.java @@ -0,0 +1,93 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupEnv; +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Sets up a pristine Jira from the official container image by driving the + * setup wizard, backed by a PostgreSQL container whose connection the image + * templates into {@code dbconfig.xml}. Gated behind + * {@code BOOTSTRAPI_SETUP_IT=true} and a license in + * {@code BOOTSTRAPI_SETUP_IT_LICENSE} (the public Jira Data Center timebomb + * license works). + */ +@Testcontainers +@EnabledIfEnvironmentVariable(named = "BOOTSTRAPI_SETUP_IT", matches = "true") +class SetupCliIT { + + static { + // the bundled docker-java client defaults to API version 1.32, which recent + // Docker daemons reject (Docker 29 requires at least 1.40); 1.41 is accepted + // by every daemon since Docker 20.10 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + } + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withNetwork(NETWORK) + .withNetworkAliases("postgres") + .withDatabaseName("jira") + .withUsername("jira") + .withPassword("jira"); + + @Container + private static final GenericContainer JIRA = new GenericContainer<>( + SetupEnv.get("BOOTSTRAPI_SETUP_IT_IMAGE", "atlassian/jira-software:11.3.8")) + .withNetwork(NETWORK) + .withExposedPorts(8080) + .withEnv("ATL_DB_TYPE", "postgres72") + .withEnv("ATL_JDBC_URL", "jdbc:postgresql://postgres:5432/jira") + .withEnv("ATL_JDBC_USER", "jira") + .withEnv("ATL_JDBC_PASSWORD", "jira") + .withEnv("JVM_MINIMUM_MEMORY", "1g") + .withEnv("JVM_MAXIMUM_MEMORY", "2g"); + + @AfterAll + static void teardown() { + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testSetup() { + final String license = SetupEnv.get("BOOTSTRAPI_SETUP_IT_LICENSE", null); + assumeTrue(license != null, "BOOTSTRAPI_SETUP_IT_LICENSE is not set"); + + final String baseUrl = "http://" + JIRA.getHost() + ":" + JIRA.getMappedPort(8080); + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "900"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME", "postgres"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_NAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Setup IT"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", license); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "admin-secret-1"); + + final SetupHttpSession session = new SetupHttpSession(baseUrl); + assertEquals(0, new SetupCli(session).run()); + + assertTrue(session.get("/status").contains("RUNNING")); + + // a second run is a no-op + assertEquals(0, new SetupCli(session).run()); + } +} diff --git a/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java new file mode 100644 index 00000000..76555ada --- /dev/null +++ b/jira/src/test/java/com/deftdevs/bootstrapi/jira/cli/SetupCliTest.java @@ -0,0 +1,180 @@ +package com.deftdevs.bootstrapi.jira.cli; + +import com.deftdevs.bootstrapi.commons.cli.SetupHttpSession; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetupCliTest { + + private HttpServer server; + private String baseUrl; + private final Map> capturedForms = new LinkedHashMap<>(); + private final List capturedPaths = new ArrayList<>(); + + @BeforeEach + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/jira"; + + System.setProperty("BOOTSTRAPI_SETUP_BASE_URL", baseUrl); + System.setProperty("BOOTSTRAPI_SETUP_TIMEOUT_SECONDS", "5"); + System.setProperty("BOOTSTRAPI_SETUP_POLL_SECONDS", "1"); + System.setProperty("BOOTSTRAPI_SETUP_RETRY_SECONDS", "0"); + System.setProperty("BOOTSTRAPI_SETUP_TITLE", "Test Jira"); + System.setProperty("BOOTSTRAPI_SETUP_LICENSE", "LICENSE-KEY"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_FULL_NAME", "Admin Admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_EMAIL", "admin@example.com"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_USERNAME", "admin"); + System.setProperty("BOOTSTRAPI_SETUP_ADMIN_PASSWORD", "secret"); + } + + @AfterEach + void teardown() { + server.stop(0); + System.getProperties().keySet().removeIf(key -> key.toString().startsWith("BOOTSTRAPI_SETUP_")); + } + + @Test + void testFullSetupRunWithLicenseRetry() { + final AtomicInteger licenseAttempts = new AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean databaseInitialized = new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicBoolean wizardComplete = new java.util.concurrent.atomic.AtomicBoolean(); + + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_HOSTNAME", "postgres"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_NAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_USERNAME", "jira"); + System.setProperty("BOOTSTRAPI_SETUP_DATABASE_PASSWORD", "jira"); + + server.createContext("/jira", exchange -> { + final String path = exchange.getRequestURI().getPath(); + switch (path) { + case "/jira/status": + respond(exchange, 200, wizardComplete.get() + ? "{\"state\":\"RUNNING\"}" + : "{\"state\":\"FIRST_RUN\"}"); + break; + case "/jira/": + case "/jira": + // an empty database puts the wizard on the database step first + respond(exchange, 200, databaseInitialized.get() + ? tokenPage("TOKEN-0") + : "

" + tokenPage("DB-TOKEN") + "
"); + break; + case "/jira/secure/SetupDatabase.jspa": + capture(exchange, path); + databaseInitialized.set(true); + respond(exchange, 200, "database ready"); + break; + case "/jira/secure/SetupApplicationProperties.jspa": + capture(exchange, path); + respond(exchange, 200, tokenPage("TOKEN-1")); + break; + case "/jira/secure/SetupLicense.jspa": + capture(exchange, path); + if (licenseAttempts.incrementAndGet() == 1) { + // a freshly started instance may answer with an error once + respond(exchange, 500, "not ready yet"); + } else { + respond(exchange, 200, tokenPage("TOKEN-2")); + } + break; + case "/jira/secure/SetupAdminAccount.jspa": + capture(exchange, path); + respond(exchange, 200, tokenPage("TOKEN-3")); + break; + case "/jira/secure/SetupMailNotifications.jspa": + capture(exchange, path); + wizardComplete.set(true); + respond(exchange, 200, "done"); + break; + default: + respond(exchange, 404, "unknown " + path); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertEquals(2, licenseAttempts.get()); + assertEquals("DB-TOKEN", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("atl_token")); + assertEquals("postgres", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("jdbcHostname")); + assertEquals("postgres72", capturedForms.get("/jira/secure/SetupDatabase.jspa").get("databaseType")); + assertEquals("TOKEN-0", capturedForms.get("/jira/secure/SetupApplicationProperties.jspa").get("atl_token")); + assertEquals("Test Jira", capturedForms.get("/jira/secure/SetupApplicationProperties.jspa").get("title")); + assertEquals("TOKEN-1", capturedForms.get("/jira/secure/SetupLicense.jspa").get("atl_token")); + assertEquals("LICENSE-KEY", capturedForms.get("/jira/secure/SetupLicense.jspa").get("setupLicenseKey")); + assertEquals("TOKEN-2", capturedForms.get("/jira/secure/SetupAdminAccount.jspa").get("atl_token")); + assertEquals("Admin Admin", capturedForms.get("/jira/secure/SetupAdminAccount.jspa").get("fullname")); + assertEquals("TOKEN-3", capturedForms.get("/jira/secure/SetupMailNotifications.jspa").get("atl_token")); + assertEquals("true", capturedForms.get("/jira/secure/SetupMailNotifications.jspa").get("noemail")); + } + + @Test + void testAlreadySetUpDoesNothing() { + server.createContext("/jira", exchange -> { + if (exchange.getRequestURI().getPath().equals("/jira/status")) { + respond(exchange, 200, "{\"state\":\"RUNNING\"}"); + } else { + capturedPaths.add(exchange.getRequestURI().getPath()); + respond(exchange, 404, "unexpected"); + } + }); + server.start(); + + final int exitCode = new SetupCli(new SetupHttpSession(baseUrl)).run(); + + assertEquals(0, exitCode); + assertTrue(capturedForms.isEmpty()); + assertTrue(capturedPaths.isEmpty()); + } + + private static String tokenPage( + final String token) { + + return ""; + } + + private void capture( + final HttpExchange exchange, + final String path) throws IOException { + + final String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + final Map form = new LinkedHashMap<>(); + for (final String pair : body.split("&")) { + final String[] parts = pair.split("=", 2); + form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : ""); + } + capturedForms.put(path, form); + } + + private static void respond( + final HttpExchange exchange, + final int status, + final String body) throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } +} diff --git a/pom.xml b/pom.xml index 126541f0..59ef9bd9 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,8 @@ 3.1.12 6.1.1 5.23.0 + 42.7.7 + 2.0.5 true published @@ -179,6 +181,24 @@ ${jersey-common.version}
+ + org.postgresql + postgresql + ${postgresql.version} + + + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + + + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + + org.glassfish jakarta.el