Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_<PRODUCT> 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

Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plugin>.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=<license> mvn -pl <product> 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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<product>-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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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...");

Check warning on line 82 in commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this use of System.out by a logger.

See more on https://sonarcloud.io/project/issues?id=deftdevs_bootstrapi&issues=AZ9ghdEk7FEeKr2nERRN&open=AZ9ghdEk7FEeKr2nERRN&pullRequest=498
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...");

Check warning on line 121 in commons/src/main/java/com/deftdevs/bootstrapi/commons/cli/SetupHttpSession.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this use of System.out by a logger.

See more on https://sonarcloud.io/project/issues?id=deftdevs_bootstrapi&issues=AZ9gqDNFRJDIKE0zvL6q&open=AZ9gqDNFRJDIKE0zvL6q&pullRequest=498
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<String> response = send(followingClient, getRequest(path));
requireSuccess("GET", path, response);
return response.body();
}

/**
* GET without following redirects, returning the redirect target if any.
*/
public Optional<String> getLocation(
final String path) {

final HttpResponse<String> 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<String, String> 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<String> 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<String> 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<String> 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);
}
}
Loading