diff --git a/.github/workflows/apprunner-ci.yml b/.github/workflows/apprunner-ci.yml new file mode 100644 index 0000000..2477b77 --- /dev/null +++ b/.github/workflows/apprunner-ci.yml @@ -0,0 +1,87 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Apprunner CI + +on: + push: + branches: [ "main" ] + paths: + - 'apprunner/**' + - '.github/**' + pull_request: + paths: + - 'apprunner/**' + - '.github/**' + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + max-parallel: 4 + matrix: + java-version: [21, 23] + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: | + 21 + ${{ matrix.java-version != '21' && matrix.java-version || '' }} + distribution: 'temurin' + + # Configure Gradle for optimal use in GiHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4 + with: + # The setup-gradle action fails, if the wrapper is not using the right version or is not present. + # Our `gradlew` validates the integrity of the `gradle-wrapper.jar`, so it's safe to disable this. + validate-wrappers: false + + - name: Code style checks and tests + run: | + cd apprunner + ./gradlew --continue check + + - name: Check Maven publication + run: | + cd apprunner + ./gradlew publishToMavenLocal + + - name: Archive test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: upload-test-artifacts-${{ matrix.java-version }} + path: | + **/build/reports/* + **/build/test-results/** diff --git a/apprunner/.gitignore b/apprunner/.gitignore new file mode 100644 index 0000000..37b6591 --- /dev/null +++ b/apprunner/.gitignore @@ -0,0 +1,46 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# Ignore Gradle wrapper jar file +gradle/wrapper/gradle-wrapper.jar +gradle/wrapper/gradle-wrapper-*.sha256 + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# IntelliJ +/.idea +*.iml +*.ipr +*.iws + +# Gradle +.gradle/ +/apprunner-build-logic/.kotlin +**/build/ +!src/**/build/ + +# Maven plugin / special +maven-plugin/target + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# macOS +*.DS_Store diff --git a/apprunner/LICENSE b/apprunner/LICENSE new file mode 100644 index 0000000..2327960 --- /dev/null +++ b/apprunner/LICENSE @@ -0,0 +1,215 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +This product includes a gradle wrapper. + +* gradlew +* gradle/wrapper/gradle-wrapper.properties + +Copyright: 2010-2019 Gradle Authors. +Home page: https://github.com/gradle/gradle +License: https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- diff --git a/apprunner/NOTICE b/apprunner/NOTICE new file mode 100644 index 0000000..cb2c8b9 --- /dev/null +++ b/apprunner/NOTICE @@ -0,0 +1,10 @@ +Apache Polaris (incubating) +Copyright 2025 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +The initial code for the apprunner project was donated +to the ASF by Dremio Corporation. (https://www.dremio.com/) copyright 2022. + +------------------------------------------------------------------------- diff --git a/apprunner/README.md b/apprunner/README.md new file mode 100644 index 0000000..54df4dd --- /dev/null +++ b/apprunner/README.md @@ -0,0 +1,365 @@ + + +# Polaris Apprunner Gradle and Maven Plugins + +Gradle and Maven plugins to run a Polaris process and "properly" terminate it for integration testing. + +## Java integration tests + +Tests that run via a Gradle `Test` type task, "decorated" with the Polaris Apprunner plugin, have access to +four system properties. Integration tests using the Maven plugin have access to the same system properties. +The names of the system properties can be changed, if needed. See the [Gradle Kotlin DSL](#kotlin-dsl--all-in-one) +and [Maven](#maven) sections below for a summary of the available options. + +* `quarkus.http.test-port` the port on which the Quarkus server listens for application HTTP requests +* `quarkus.management.test-port` the URL on which the Quarkus server listens for management HTTP requests, this + URL is the one emitted by Quarkus during startup and will contain `0.0.0.0` as the host. +* `quarkus.http.test-url` the port on which the Quarkus server listens for application HTTP requests +* `quarkus.management.test-url` the URL on which the Quarkus server listens for management HTTP requests, this + URL is the one emitted by Quarkus during startup and will contain `0.0.0.0` as the host. + +The preferred way to get the URI/URL for application HTTP requests is to get the `quarkus.http.test-port` system +property and construct the URI against `127.0.0.1` (or `::1` if you prefer). + +```java +public class ITWorksWithPolaris { + static final URI POLARIS_SERVER_URI = + URI.create( + String.format( + "http://127.0.0.1:%s/", + requireNonNull( + System.getProperty("quarkus.http.test-port"), + "Required system property quarkus.http.test-port is not set"))); + + @Test + public void pingPolaris() { + // Use the POLARIS_SERVER_URI in your tests ... + } +} +``` + +## Gradle + +The Polaris Apprunner Gradle ensures that the Polaris Quarkus Server is up and running if and when the configured +test tasks run. It also ensures, as long as you do not forcibly kill Gradle processes, that the Polaris Quarkus Server +is shutdown after the configured test task has finished. Each configured test task gets its "own" Polaris Quarkus +Server started up. + +It is possible to configure multiple tasks/test-suites within a Gradle project to run with a Polaris Quarkus Server. +Since tasks of the same Gradle project do not run concurrently (as of today), there are should be no conflicts, except +potentially the working directory. + +### Using the plugin in projects in the polaris-tools repository + +1. include the apprunner build in your project by adding the following snippet at the beginning of your + `settings.gradle.kts` file: + ```kotlin + includeBuild("../apprunner") { name = "polaris-apprunner" } + ``` + +### Kotlin DSL / step by step + +`build.gradle.kts` + +1. add the plugin + ```kotlin + plugins { + // Replace the version with a release version of the Polaris Apprunner Plugin. + // Omit the version when using the apprunner plugin as a Gradle include build. + id("org.apache.polaris.apprunner") version "0.0.0" + } + ``` +2. Add the Polaris Quarkus Server as a dependency + ```kotlin + // Gradle configuration to reference the tarball + val polarisTarball by + configurations.creating { description = "Used to reference the distribution tarball" } + + dependencies { + polarisTarball("org.apache.polaris:polaris-quarkus-server:1.0.0-incubating-SNAPSHOT:@tgz") + } + + // Directory where the Polaris tarball is extracted to + val unpackedTarball = project.layout.buildDirectory.dir("polaris-tarball") + + // Extracts the Polaris tarball, truncating the path + val polarisUnpackedTarball by + tasks.registering(Sync::class) { + inputs.files(polarisTarball) + destinationDir = unpackedTarball.get().asFile + from(provider { tarTree(polarisTarball.singleFile) }) + eachFile { + // truncates the path (removes the first path element) + relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) + } + includeEmptyDirs = false + } + ``` +3. If necessary, add a separate test suite + ```kotlin + testing { + suites { + val polarisServerTest by registering(JvmTestSuite::class) { + // more test-suite related configurations + } + } + } + + tasks.named("polarisServerTest") { + // Dependency to have the extracted tarball + dependsOn(polarisUnpackedTarball) + } + ``` +4. Tell the Apprunner plugin which test tasks need the Polaris server + ```kotlin + polarisQuarkusApp { + // Add the name of the test task - usually the same as the name of your test source + includeTask(tasks.named("polarisServerTest")) + // Reference the quarkus-run.jar in the tarball, apprunner plugin will then run that jar + executableJar = provider { unpackedTarball.get().file("quarkus-run.jar") } + } + ``` + +Note: the above also works within the `:polaris-quarkus-server` project, but the test suite must be neither +`test` nor `intTest` nor `integrationTest`. + +### Kotlin DSL / all configuration options + +```kotlin +polarisQuarkusApp { + // Ensure that the `test` task has a Polaris Server available. + includeTask(tasks.named("polarisServerIntegrationTest")) + // Note: prefer setting up separate `polarisServerIntegrationTest` test suite (the name is up to you!), + // see https://docs.gradle.org/current/userguide/jvm_test_suite_plugin.html + + // Override the default Java version (21) to run the Polaris server / Quarkus. + // Must be at least 21! + javaVersion.set(21) + // Additional environment variables for the Polaris server / Quarkus + // (Added to the Gradle inputs used to determine Gradle's UP-TO-DATE status) + environment.put("MY_ENV_VAR", "value") + // Additional environment variables for the Polaris server / Quarkus + // (NOT a Gradle inputs used to determine Gradle's UP-TO-DATE status) + // Put system specific variables (e.g. local paths) here + environmentNonInput.put("MY_ENV_VAR", "value") + // System properties for the Polaris server / Quarkus + // (Added to the Gradle inputs used to determine Gradle's UP-TO-DATE status) + systemProperties.put("my.sys.prop", "value") + // System properties for the Polaris server / Quarkus + // (NOT a Gradle inputs used to determine Gradle's UP-TO-DATE status) + // Put system specific variables (e.g. local paths) here + systemPropertiesNonInput.put("my.sys.prop", "value") + // JVM arguments for the Polaris server JVM (list of strings) + // (Added to the Gradle inputs used to determine Gradle's UP-TO-DATE status) + jvmArguments.add("some-arg") + // JVM arguments for the Polaris server JVM (list of strings) + // (NOT a Gradle inputs used to determine Gradle's UP-TO-DATE status) + // Put system specific variables (e.g. local paths) here + jvmArgumentsNonInput.add("some-arg") + // Use this (full) path to the executable jar of the Polaris server. + // Note: This option should generally be avoided in build scripts, prefer the 'polarisQuarkusServer' + // configuration mentioned above. + executableJar = file("/my/custom/polars-quarkus-server.jar") + // Override the working directory for the Polaris Quarkus server, defaults to `polaris-quarkus-server/` + // in the Gradle project's `build/` directory. + workingDirectory = file("/i/want/it/to/run/here") + // override the default timeout of 30 seconds to wait for the Polaris Quarkus Server to emit the + // listen URLs. + timeToListenUrlMillis = 30000 + // Override the default timeout of 15 seconds to wait for the Polaris Quarkus Server to stop before + // it is forcefully killed + timeToStopMillis = 15000 + // Arguments for the Polaris server (list of strings) + // (Added to the Gradle inputs used to determine Gradle's UP-TO-DATE status) + arguments.add("some-arg") + // Arguments for the Polaris server (list of strings) + // (NOT a Gradle inputs used to determine Gradle's UP-TO-DATE status) + // Put system specific variables here + argumentsNonInput.add("some-arg") + // The following options can be used to use different property names than described above + // in this README + httpListenPortProperty = "quarkus.http.test-port" + httpListenUrlProperty = "quarkus.http.test-url" + managementListenPortProperty = "quarkus.management.test-port" + managementListenUrlProperty = "quarkus.management.test-url" +} +``` + +### Groovy DSL + +`build.gradle` - note: the version number needs to be replaced with a (not yet existing) binary release of +Apache Polaris. + +```groovy +plugins { + id 'java-library' + id 'org.apache.polaris.apprunner' version "0.0.0" +} + +dependencies { + // specify the GAV of the Polaris Quarkus server runnable (uber-jar) + polarisQuarkusServer "org.apache.polaris:polaris-quarkus-server:0.0.0:runner" +} + +polarisQuarkusApp { + // Ensure that the `test` task has a Polaris Server available when the tests run. + includeTask(tasks.named("test")) + // Note: prefer setting up separate `polarisServerIntegrationTest` test suite (the name is up to you!), + // see https://docs.gradle.org/current/userguide/jvm_test_suite_plugin.html + + // See the Kotlin DSL description above for information about the options +} +``` + +## Maven + +The `org.apache.polaris.apprunner:polaris-apprunner-maven-plugin` Maven plugin should be used together with the +standard `maven-failsafe-plugin` + +`pom.xml` - note: the version number needs to be replaced with a (not yet existing) binary release of +Apache Polaris. + +```xml + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + org.apache.polaris.apprunner + polaris-apprunner-maven-plugin + > + 0.0.0 + + + > + org.apache.polaris:polaris-quarkus-server:jar:runner:0.0.0 + + + bar + + + + world + + + + + + + start + pre-integration-test + + start + + + + + stop + post-integration-test + + stop + + + + + + + +``` + +## Implicit Quarkus options + +The plugins always pass the following configuration options as system properties to Quarkus: + +``` +quarkus.http.port=0 +quarkus.management.port=0 +quarkus.log.level=INFO +quarkus.log.console.level=INFO +``` + +Those are meant to let Quarkus bind to a random-ish port, so that the started instances do not conflict with anything +else running on the system and that the necessary log line containing the listen-URLs gets emitted. + +You can explicitly override those via the `systemProperties` options of the Gradle and Maven plugins. + +## Developing the plugins + +The Polaris Apprunner plugins are built via a Gradle "included build" (composite build). As generally with composite +builds, task selection via `gradlew` does _not_ get propagated to included builds. This is especially true for +tasks like `spotlessApply`. + +In other words, running `./gradlew spotlessApply` against the "main" Polaris build will run `spotlessApply` +_only_ in the projects in the "main" Polaris build, but _not_ in the apprunner build. This is also true for other +tasks like `check`. + +This means, the easiest way is to just change the current working directory to `tools/apprunner` and work from there. +Publishing the plugins also has to be done from the `tools/apprunner` directory. This is why `gradlew` & co are +present in `tools/apprunner`. + +## FAQ + +### Does it have to be a Polaris Quarkus server? + +The plugins work with any Quarkus application that listens for HTTP requests. + +The only requirement of the Polaris Apprunner plugins for the runnable jar is that it is a Quarkus (web) server, +that emits at least the HTTP listen URL (and optionally the management listen URL). + +This means, that this plugin can be used with basically any Quarkus based application, whether it's Apache Polaris +or Nessie or something else. + +### The plugin always times out starting the server, even if the server starts up + +Make sure that Quarkus emits a line like the following: + +``` +2025-01-16 13:29:25,959 INFO [io.quarkus] (main) Apache Polaris Server (incubating) 1.0.0-incubating-SNAPSHOT on JVM (powered by Quarkus 3.17.7) started in 0.998s. Listening on: http://0.0.0.0:8181. Management interface listening on http://0.0.0.0:8182. +``` + +The important part is `Listening on: http://0.0.0.0:8181. Management interface listening on http://0.0.0.0:8182.`, +especially the `Listening on: http://0.0.0.0:8181.` is mandatory, the port number used by Quarkus does not matter. + +If that line does not get logged to stdout, the Polaris Apprunner plugin does not detect the Quarkus application to +be running. Make sure that your Quarkus logging configuration allows logging this line to stdout. + +## Origin of the Polaris Apprunner plugins + +The Polaris Apprunner Gradle and Maven plugins are based +on [projectnessie's apprunner](https://github.com/projectnessie/nessie-apprunner). diff --git a/apprunner/apprunner-build-logic/build.gradle.kts b/apprunner/apprunner-build-logic/build.gradle.kts new file mode 100644 index 0000000..9b9df89 --- /dev/null +++ b/apprunner/apprunner-build-logic/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +plugins { `kotlin-dsl` } + +dependencies { + implementation(gradleKotlinDsl()) + implementation(baselibs.errorprone) + implementation(baselibs.idea.ext) + implementation(baselibs.nexus.publish) + implementation(baselibs.shadow) + implementation(baselibs.spotless) +} diff --git a/apprunner/apprunner-build-logic/settings.gradle.kts b/apprunner/apprunner-build-logic/settings.gradle.kts new file mode 100644 index 0000000..e4031cd --- /dev/null +++ b/apprunner/apprunner-build-logic/settings.gradle.kts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +dependencyResolutionManagement { + versionCatalogs { create("baselibs") { from(files("../gradle/baselibs.versions.toml")) } } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-java.gradle.kts b/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-java.gradle.kts new file mode 100644 index 0000000..e5064f6 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-java.gradle.kts @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.Properties +import com.diffplug.spotless.FormatterFunc +import java.io.Serializable +import net.ltgt.gradle.errorprone.CheckSeverity +import net.ltgt.gradle.errorprone.errorprone +import publishing.PublishingHelperPlugin + +plugins { + `java-library` + `java-test-fixtures` + `jvm-test-suite` + id("com.diffplug.spotless") + id("net.ltgt.errorprone") +} + +apply() + +tasks.withType(JavaCompile::class.java).configureEach { + options.compilerArgs.addAll(listOf("-Xlint:unchecked", "-Xlint:deprecation")) + options.errorprone.disableAllWarnings = true + options.errorprone.disableWarningsInGeneratedCode = true + options.errorprone.excludedPaths = + ".*/${project.layout.buildDirectory.get().asFile.relativeTo(projectDir)}/generated/.*" + val errorproneRules = rootProject.projectDir.resolve("codestyle/errorprone-rules.properties") + inputs.file(errorproneRules).withPathSensitivity(PathSensitivity.RELATIVE) + options.errorprone.checks.putAll(provider { memoizedErrorproneRules(errorproneRules) }) + options.release = 21 +} + +private fun memoizedErrorproneRules(rulesFile: File): Map = + rulesFile.reader().use { + val rules = Properties() + rules.load(it) + rules + .mapKeys { e -> (e.key as String).trim() } + .mapValues { e -> (e.value as String).trim() } + .filter { e -> e.key.isNotEmpty() && e.value.isNotEmpty() } + .mapValues { e -> CheckSeverity.valueOf(e.value) } + .toMap() + } + +tasks.register("compileAll").configure { + group = "build" + description = "Runs all compilation and jar tasks" + dependsOn(tasks.withType(), tasks.withType()) +} + +tasks.register("format").configure { + group = "verification" + description = "Runs all code formatting tasks" + dependsOn("spotlessApply") +} + +tasks.named("test").configure { jvmArgs("-Duser.language=en") } + +testing { + suites { + withType { + val libs = versionCatalogs.named("libs") + + useJUnitJupiter( + libs + .findLibrary("junit-bom") + .orElseThrow { GradleException("junit-bom not declared in libs.versions.toml") } + .map { it.version!! } + ) + + dependencies { + implementation(project()) + implementation(testFixtures(project())) + implementation( + libs.findLibrary("assertj-core").orElseThrow { + GradleException("assertj-core not declared in libs.versions.toml") + } + ) + } + + targets.all { + if (testTask.name != "test") { + testTask.configure { shouldRunAfter("test") } + } + } + } + } +} + +dependencies { + val libs = versionCatalogs.named("libs") + testFixturesImplementation( + platform( + libs.findLibrary("junit-bom").orElseThrow { + GradleException("junit-bom not declared in libs.versions.toml") + } + ) + ) + testFixturesImplementation("org.junit.jupiter:junit-jupiter") + testFixturesImplementation( + libs.findLibrary("assertj-core").orElseThrow { + GradleException("assertj-core not declared in libs.versions.toml") + } + ) +} + +spotless { + java { + target("src/main/java/**/*.java", "src/testFixtures/java/**/*.java", "src/test/java/**/*.java") + googleJavaFormat() + licenseHeaderFile(rootProject.file("codestyle/copyright-header-java.txt")) + endWithNewline() + custom( + "disallowWildcardImports", + object : Serializable, FormatterFunc { + override fun apply(text: String): String { + val regex = "~/import .*\\.\\*;/".toRegex() + if (regex.matches(text)) { + throw GradleException("Wildcard imports disallowed - ${regex.findAll(text)}") + } + return text + } + }, + ) + toggleOffOn() + } + kotlinGradle { + ktfmt().googleStyle() + licenseHeaderFile(rootProject.file("codestyle/copyright-header-java.txt"), "$") + target("*.gradle.kts") + } + format("xml") { + target("src/**/*.xml", "src/**/*.xsd") + targetExclude("codestyle/copyright-header.xml") + eclipseWtp(com.diffplug.spotless.extra.wtp.EclipseWtpFormatterStep.XML) + .configFile(rootProject.file("codestyle/org.eclipse.wst.xml.core.prefs")) + // getting the license-header delimiter right is a bit tricky. + // licenseHeaderFile(rootProject.file("codestyle/copyright-header.xml"), '<^[!?].*$') + } +} + +dependencies { errorprone(versionCatalogs.named("libs").findLibrary("errorprone").get()) } + +java { + withJavadocJar() + withSourcesJar() +} + +tasks.withType().configureEach { + val opt = options as CoreJavadocOptions + // don't spam log w/ "warning: no @param/@return" + opt.addStringOption("Xdoclint:-reference", "-quiet") +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-root.gradle.kts b/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-root.gradle.kts new file mode 100644 index 0000000..c6988f2 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/polaris-apprunner-root.gradle.kts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import org.gradle.kotlin.dsl.apply +import publishing.PublishingHelperPlugin + +plugins { id("com.diffplug.spotless") } + +apply() + +spotless { + kotlinGradle { + ktfmt().googleStyle() + licenseHeaderFile(rootProject.file("codestyle/copyright-header-java.txt"), "$") + target("*.gradle.kts", "build-logic/*.gradle.kts", "build-logic/src/**/*.kt*") + } +} + +tasks.register("compileAll").configure { + group = "build" + description = "Runs all compilation and jar tasks" + dependsOn(tasks.withType(), tasks.withType()) +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/MemoizedGitInfo.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/MemoizedGitInfo.kt new file mode 100644 index 0000000..58d93f4 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/MemoizedGitInfo.kt @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.java.archives.Attributes +import org.gradle.kotlin.dsl.extra + +/** + * Container to memoize Git information retrieved via `git` command executions across all Gradle + * projects. Jar release artifacts get some attributes added to the jar manifest, which can be quite + * useful for released jars. + */ +internal class MemoizedGitInfo { + companion object { + private fun execProc(rootProject: Project, cmd: String, vararg args: Any): String { + var out = + rootProject.providers + .exec { + executable = cmd + args(args.toList()) + } + .standardOutput + .asText + .get() + return out.trim() + } + + fun gitInfo(rootProject: Project, attribs: Attributes) { + val props = gitInfo(rootProject) + attribs.putAll(props) + } + + fun gitInfo(rootProject: Project): Map { + return if (rootProject.extra.has("gitReleaseInfo")) { + @Suppress("UNCHECKED_CAST") + rootProject.extra["gitReleaseInfo"] as Map + } else { + val isRelease = rootProject.hasProperty("release") + val gitHead = execProc(rootProject, "git", "rev-parse", "HEAD") + val gitDescribe = + if (isRelease) { + try { + execProc(rootProject, "git", "describe", "--tags") + } catch (e: Exception) { + throw GradleException("'git describe --tags' failed - no Git tag?", e) + } + } else { + execProc(rootProject, "git", "describe", "--always", "--dirty") + } + val timestamp = execProc(rootProject, "date", "+%Y-%m-%d-%H:%M:%S%:z") + val system = execProc(rootProject, "uname", "-a") + val javaVersion = System.getProperty("java.version") + + val version = rootProject.version.toString() + val info = + mapOf( + "Implementation-Version" to version, + "Apache-Polaris-Version" to version, + "Apache-Polaris-Is-Release" to isRelease.toString(), + "Apache-Polaris-Build-Git-Head" to gitHead, + "Apache-Polaris-Build-Git-Describe" to gitDescribe, + "Apache-Polaris-Build-Timestamp" to timestamp, + "Apache-Polaris-Build-System" to system, + "Apache-Polaris-Build-Java-Version" to javaVersion, + ) + rootProject.extra["gitReleaseInfo"] = info + return info + } + } + } +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperExtension.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperExtension.kt new file mode 100644 index 0000000..51a50c0 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperExtension.kt @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import java.io.File +import javax.inject.Inject +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import org.gradle.kotlin.dsl.property + +/** + * Gradle plugin extension object for the `PublishingHelperPlugin. Most attributes are likely never + * changed from the default values. + * + * Apache podlings need to specify the PPMC members and committers manually, Apache TLPs don't + * populate these properties. + */ +abstract class PublishingHelperExtension +@Inject +constructor(objectFactory: ObjectFactory, project: Project) { + // the following are only relevant on the root project + + /** + * Lowercase ASF project ID, as present in keys in the JSON docs describing the projects (for + * example in `https://whimsy.apache.org/public/public_ldap_projects.json`). + */ + val asfProjectId = objectFactory.property().convention(project.name) + + /** Used to override the full project name, for example `Apache Polaris`. */ + val overrideName = objectFactory.property() + /** Used to override the project description as it appears in published Maven poms. */ + val overrideDescription = objectFactory.property() + /** Used to override the project URL as it appears in published Maven poms. */ + val overrideProjectUrl = objectFactory.property() + /** Used to override the name of the GitHub repo in the apache organization. Defaults to the project ID. */ + val githubRepositoryName = objectFactory.property() + /** Used to override the project's SCM as it appears in published Maven poms. Default is derived from `githubRepositoryName`. */ + val overrideScm = objectFactory.property() + /** Used to override the project's issue management URL as it appears in published Maven poms. */ + val overrideIssueManagement = objectFactory.property() + /** Prefix for the tag published for non-SNAPSHOT versions in the Maven poms. */ + val overrideTagPrefix = objectFactory.property() + + /** The published distributables, including the source tarball, base file name. */ + val baseName = + objectFactory + .property() + .convention(project.provider { "apache-${asfProjectId.get()}-${project.version}" }) + val distributionDir = + objectFactory.directoryProperty().convention(project.layout.buildDirectory.dir("distributions")) + val sourceTarball = + objectFactory + .fileProperty() + .convention(project.provider { distributionDir.get().file("${baseName.get()}.tar.gz") }) + + /** List of mailing-lists. */ + val mailingLists = objectFactory.listProperty(String::class.java).convention(emptyList()) + + fun distributionFile(ext: String): File = + distributionDir.get().file("${baseName.get()}.$ext").asFile +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperPlugin.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperPlugin.kt new file mode 100644 index 0000000..1ae440d --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/PublishingHelperPlugin.kt @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import com.github.jengelman.gradle.plugins.shadow.ShadowPlugin +import javax.inject.Inject +import org.gradle.api.* +import org.gradle.api.component.SoftwareComponentFactory +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.plugins.MavenPublishPlugin +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.jvm.tasks.Jar +import org.gradle.kotlin.dsl.* +import org.gradle.plugins.signing.SigningExtension +import org.gradle.plugins.signing.SigningPlugin + +/** + * Release-publishing helper plugin to generate publications that pass Sonatype validations, + * generate Apache release source tarball. + * + * The `release` Gradle project property triggers: signed artifacts + jars with Git information. The + * current Git HEAD must point to a Git tag. + * + * The `jarWithGitInfo` Gradle project property triggers: jars with Git information (not necessary + * with `release`). + * + * The task `sourceTarball` (available on the root project) generates a source tarball using `git + * archive`. + * + * The task `releaseEmailTemplate` generates the release-vote email subject + body. Outputs on the + * console and in the `build/distributions/` directory. + * + * Signing tip: If you want to use `gpg-agent`, set the `useGpgAgent` Gradle project property + * + * The following command publishes the project artifacts to your local maven repository, generates + * the source tarball - and uses `gpg-agent` to sign all artifacts and the tarball. Note that this + * requires a Git tag! + * + * ``` + * ./gradlew publishToMavenLocal sourceTarball -Prelease -PuseGpgAgent + * ``` + * + * You can generate signed artifacts when using the `signArtifacts` project property: + * ``` + * ./gradlew publishToMavenLocal sourceTarball -PsignArtifacts -PuseGpgAgent + * ``` + */ +@Suppress("unused") +class PublishingHelperPlugin +@Inject +constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Plugin { + override fun apply(project: Project): Unit = + project.run { + extensions.create("publishingHelper", PublishingHelperExtension::class.java) + + val isRelease = project.hasProperty("release") + val isSigning = isRelease || project.hasProperty("signArtifacts") + + // Adds Git/Build/System related information to the generated jars, if the `release` project + // property is present. Do not add that information in development builds, so that the + // generated jars are still cacheable for Gradle. + if (isRelease || project.hasProperty("jarWithGitInfo")) { + // Runs `git`, considered expensive, so guarded behind project properties. + tasks.withType().configureEach { + manifest { MemoizedGitInfo.gitInfo(rootProject, attributes) } + } + + addAdditionalJarContent(this) + } + + apply(plugin = "maven-publish") + apply(plugin = "signing") + + // Generate a source tarball for a release to be uploaded to + // https://dist.apache.org/repos/dist/dev//apache--/ + if (project == rootProject) { + configureOnRootProject(project) + } + + if (isSigning) { + plugins.withType().configureEach { + configure { + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKey, signingPassword) + val publishing = project.extensions.getByType(PublishingExtension::class.java) + afterEvaluate { sign(publishing.publications.getByName("maven")) } + + if (project.hasProperty("useGpgAgent")) { + useGpgCmd() + } + } + } + } + + // Gradle complains when a Gradle module metadata ("pom on steroids") is generated with an + // enforcedPlatform() dependency - but Quarkus requires enforcedPlatform(), so we have to + // allow it. + tasks.withType().configureEach { + suppressedValidationErrors.add("enforced-platform") + } + + plugins.withType().configureEach { + configure { + publications { + register("maven") { + val mavenPublication = this + afterEvaluate { + // This MUST happen in an 'afterEvaluate' to ensure that the Shadow*Plugin has + // been applied. + if (project.plugins.hasPlugin(ShadowPlugin::class.java)) { + configureShadowPublishing(project, mavenPublication, softwareComponentFactory) + } else { + from(components.firstOrNull { c -> c.name == "javaPlatform" || c.name == "java" }) + } + + suppressPomMetadataWarningsFor("testFixturesApiElements") + suppressPomMetadataWarningsFor("testFixturesRuntimeElements") + } + + if ( + plugins.hasPlugin("java-test-fixtures") && + project.layout.projectDirectory.dir("src/testFixtures").asFile.exists() + ) { + val testFixturesSourcesJar by + tasks.registering(org.gradle.api.tasks.bundling.Jar::class) { + val sourceSets: SourceSetContainer by project + from(sourceSets.named("testFixtures").get().allSource) + archiveClassifier.set("test-fixtures-sources") + } + tasks.named("testFixturesJavadoc") { isFailOnError = false } + val testFixturesJavadocJar by + tasks.registering(org.gradle.api.tasks.bundling.Jar::class) { + from(tasks.named("testFixturesJavadoc")) + archiveClassifier.set("test-fixtures-javadoc") + } + + artifact(testFixturesSourcesJar) + artifact(testFixturesJavadocJar) + } + + tasks.named("generatePomFileForMavenPublication").configure { + configurePom(project, mavenPublication, this) + } + } + } + } + } + } +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/configurePom.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/configurePom.kt new file mode 100644 index 0000000..a28c3b0 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/configurePom.kt @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import groovy.util.Node +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.artifacts.component.ModuleComponentSelector +import org.gradle.api.provider.Provider +import org.gradle.api.publish.maven.MavenPom +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.internal.extensions.stdlib.capitalized + +/** + * Configures the content of the generated `pom.xml` files. + * + * For all projects except the root project, the pom gets the ``, ``, + * ``, `` elements and fixes dependencies in `` to be + * consumable by Maven. + * + * The root project generates the parent pom, containing all the necessary elements to pass Sonatype + * validation and some more information like `` and ``. Most of the + * information is taken from publicly consumable Apache project information from + * `https://projects.apache.org/json/projects/>.json`. `` contains all + * (P)PMC members and committers from that project info JSON, ordered by real name. `` + * is taken from GitHub's + * `https://api.github.com/repos/apache//contributors?per_page=1000` endpoint to give + * all contributors credit, ordered by number of contributions (as returned by that endpoint). + */ +internal fun configurePom(project: Project, mavenPublication: MavenPublication, task: Task) = + mavenPublication.run { + val e = project.extensions.getByType(PublishingHelperExtension::class.java) + + pom { + if (project != project.rootProject) { + // Add the license to every pom to make it easier for downstream project to retrieve the + // license. + licenses { + license { + name.set("Apache-2.0") // SPDX identifier + } + } + + withXml { + val projectNode = asNode() + + val parentNode = projectNode.appendNode("parent") + val parent = project.parent!! + parentNode.appendNode("groupId", parent.group) + parentNode.appendNode("artifactId", parent.name) + parentNode.appendNode("version", parent.version) + + addMissingMandatoryDependencyVersions(project, projectNode) + } + } else { + val mavenPom = this + + task.doFirst { + mavenPom.run { + val asfName = e.asfProjectId.get() + val projectPeople = fetchProjectPeople(asfName) + + organization { + name.set("The Apache Software Foundation") + url.set("https://www.apache.org/") + } + licenses { + license { + name.set("Apache-2.0") // SPDX identifier + url.set(projectPeople.licenseUrl) + } + } + mailingLists { + e.mailingLists.get().forEach { ml -> + mailingList { + name.set("${ml.capitalized()} Mailing List") + subscribe.set("$ml-subscribe@$asfName.apache.org") + unsubscribe.set("$ml-ubsubscribe@$asfName.apache.org") + post.set("$ml@$asfName.apache.org") + archive.set("https://lists.apache.org/list.html?$ml@$asfName.apache.org") + } + } + } + + val githubRepoName: Provider = e.githubRepositoryName.orElse(asfName) + val codeRepo: Provider = e.overrideScm.orElse( + githubRepoName.map { r -> "https://github.com/apache/$r" }.orElse(projectPeople.repository)) + + scm { + val codeRepoString: String = codeRepo.get() + connection.set("scm:git:$codeRepoString") + developerConnection.set("scm:git:$codeRepoString") + url.set("$codeRepoString/tree/main") + val version = project.version.toString() + if (!version.endsWith("-SNAPSHOT")) { + val tagPrefix: String = + e.overrideTagPrefix.orElse("apache-${projectPeople.apacheId}").get() + tag.set("$tagPrefix-$version") + } + } + issueManagement { + val issuesUrl: Provider = codeRepo.map { r -> "$r/issues" }.orElse(projectPeople.bugDatabase) + url.set(e.overrideIssueManagement.orElse(issuesUrl)) + } + + name.set(e.overrideName.orElse("Apache ${projectPeople.name}")) + description.set(e.overrideDescription.orElse(projectPeople.description)) + url.set(e.overrideProjectUrl.orElse(projectPeople.website)) + inceptionYear.set(projectPeople.inceptionYear.toString()) + + developers { + projectPeople.people.forEach { person -> + developer { + this.id.set(person.apacheId) + this.name.set(person.name) + this.organization.set("Apache Software Foundation") + this.email.set("${person.apacheId}@apache.org") + this.roles.addAll(person.roles) + } + } + } + + addContributorsToPom(mavenPom, githubRepoName.get(), "Apache ${projectPeople.name}") + } + } + } + } + } + +/** Adds contributors as returned by GitHub, in descending `contributions` order. */ +fun addContributorsToPom(mavenPom: MavenPom, githubRepoName: String, asfProjectName: String) = + mavenPom.run { + contributors { + val contributors: List> = + parseJson("https://api.github.com/repos/apache/$githubRepoName/contributors?per_page=1000") + contributors + .filter { contributor -> contributor["type"] == "User" } + .forEach { contributor -> + contributor { + name.set(contributor["login"] as String) + url.set(contributor["url"] as String) + organization.set("$asfProjectName, GitHub contributors") + organizationUrl.set("https://github.com/apache/$githubRepoName") + } + } + } + } + +/** + * Scans the generated `pom.xml` for `` in `` that do not have a + * `` and adds one, if possible. Maven kinda requires `` tags there, even if the + * `` without a `` is a bom and that bom's version is available transitively. + */ +fun addMissingMandatoryDependencyVersions(project: Project, projectNode: Node) { + xmlNode(xmlNode(projectNode, "dependencyManagement"), "dependencies")?.children()?.forEach { + val dependency = it as Node + if (xmlNode(dependency, "version") == null) { + val depGroup = xmlNode(dependency, "groupId")!!.text() + val depName = xmlNode(dependency, "artifactId")!!.text() + + var depResult = + findDependency(project.configurations.findByName("runtimeClasspath"), depGroup, depName) + if (depResult == null) { + depResult = + findDependency( + project.configurations.findByName("testRuntimeClasspath"), + depGroup, + depName, + ) + } + + if (depResult != null) { + val req = depResult.requested as ModuleComponentSelector + dependency.appendNode("version", req.version) + } + } + } +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/digest-task.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/digest-task.kt new file mode 100644 index 0000000..b0b62a5 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/digest-task.kt @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import java.security.MessageDigest +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault +abstract class GenerateDigest @Inject constructor(objectFactory: ObjectFactory) : DefaultTask() { + + @get:InputFile val file = objectFactory.fileProperty() + @get:Input val algorithm = objectFactory.property(String::class.java).convention("SHA-512") + @get:OutputFile + val outputFile = + objectFactory.fileProperty().convention { + val input = file.get().asFile + val algo = algorithm.get() + input.parentFile.resolve("${input.name}-${algo.replace("-", "").lowercase()}") + } + + @TaskAction + fun generate() { + val input = file.get().asFile + val digestFile = outputFile.get().asFile + val md = MessageDigest.getInstance(algorithm.get()) + input.inputStream().use { + val buffered = it.buffered(8192) + val buf = ByteArray(8192) + var rd: Int + while (true) { + rd = buffered.read(buf) + if (rd == -1) break + md.update(buf, 0, rd) + } + + digestFile.writeText( + md.digest().joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } + + " ${input.name}" + ) + } + } +} diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/maven-utils.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/maven-utils.kt new file mode 100644 index 0000000..5abeb0b --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/maven-utils.kt @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.plugins.JavaLibraryPlugin +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.provideDelegate + +@CacheableTask +abstract class GeneratePomProperties : DefaultTask() { + @Suppress("unused") @get:Input abstract val pomInputs: ListProperty + + @get:OutputDirectory abstract val destinationDir: DirectoryProperty + + init { + pomInputs.convention(listOf(project.group.toString(), project.name, project.version.toString())) + destinationDir.convention(project.layout.buildDirectory.dir("generated/pom-properties")) + } + + @TaskAction + fun generate() { + val buildDir = destinationDir.get().asFile + buildDir.deleteRecursively() + val targetDir = buildDir.resolve("META-INF/maven/${project.group}/${project.name}") + targetDir.mkdirs() + targetDir + .resolve("pom.properties") + .writeText( + """ + # Generated by the Apache Polaris build. + groupId=${project.group} + artifactId=${project.name} + version=${project.version} + """ + .trimIndent() + ) + } +} + +/** + * Adds convenient, but not strictly necessary information to each generated "main" jar. + * + * This includes `pom.properties` and `pom.xml` files where Maven places those, in + * `META-INF/maven/group-id/artifact-id/`. Also adds the `NOTICE` and `LICENSE` files in `META-INF`, + * which makes it easier for license scanners. + */ +fun addAdditionalJarContent(project: Project): Unit = + project.run { + project.plugins.withType(JavaLibraryPlugin::class.java) { + val generatePomProperties = + tasks.register("generatePomProperties", GeneratePomProperties::class.java) {} + + val additionalJarContent = + tasks.register("additionalJarContent", Sync::class.java) { + // Have to manually declare the inputs of this task here on top of the from/include below + inputs.files(rootProject.layout.files("LICENSE", "NOTICE")) + inputs.property("GAV", "${project.group}:${project.name}:${project.version}") + dependsOn("generatePomFileForMavenPublication") + from(rootProject.rootDir) { + include("LICENSE", "NOTICE") + eachFile { this.path = "META-INF/$sourceName" } + } + from(tasks.named("generatePomFileForMavenPublication")) { + include("pom-default.xml") + eachFile { this.path = "META-INF/maven/${project.group}/${project.name}/pom.xml" } + } + into(layout.buildDirectory.dir("license-for-jar")) + } + + tasks.named("processResources") { dependsOn("additionalJarContent") } + + val sourceSets: SourceSetContainer by project + sourceSets.named("main") { + resources.srcDir(additionalJarContent) + resources.srcDir(generatePomProperties) + } + } + } diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/rootProject.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/rootProject.kt new file mode 100644 index 0000000..3283f59 --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/rootProject.kt @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import io.github.gradlenexus.publishplugin.NexusPublishExtension +import io.github.gradlenexus.publishplugin.NexusPublishPlugin +import io.github.gradlenexus.publishplugin.internal.StagingRepositoryDescriptorRegistryBuildService +import org.gradle.api.Project +import org.gradle.api.services.BuildServiceRegistration +import org.gradle.api.tasks.Exec +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register +import org.gradle.plugins.signing.Sign + +/** + * Configures Apache project specific publishing tasks on the root project, for example the + * source-tarball related tasks. + */ +internal fun configureOnRootProject(project: Project) = + project.run { + apply() + + val isRelease = project.hasProperty("release") + val isSigning = isRelease || project.hasProperty("signArtifacts") + + val sourceTarball = tasks.register("sourceTarball") + sourceTarball.configure { + group = "build" + description = + "Generate a source tarball for a release to be uploaded to dist.apache.org/repos/dist" + + val e = project.extensions.getByType(PublishingHelperExtension::class.java) + doFirst { mkdir(e.distributionDir) } + + executable = "git" + args( + "archive", + "--prefix=${e.baseName.get()}/", + "--format=tar.gz", + "--output=${e.sourceTarball.get().asFile.relativeTo(projectDir)}", + "HEAD", + ) + workingDir(project.projectDir) + } + + val digestSourceTarball = + tasks.register("digestSourceTarball") { + description = "Generate the source tarball digest" + mustRunAfter(sourceTarball) + file.set { + val e = project.extensions.getByType(PublishingHelperExtension::class.java) + e.sourceTarball.get().asFile + } + } + + sourceTarball.configure { finalizedBy(digestSourceTarball) } + + if (isSigning) { + val signSourceTarball = + tasks.register("signSourceTarball") { + description = "Sign the source tarball" + mustRunAfter(sourceTarball) + doFirst { + val e = project.extensions.getByType(PublishingHelperExtension::class.java) + sign(e.sourceTarball.get().asFile) + } + } + sourceTarball.configure { finalizedBy(signSourceTarball) } + } + + val releaseEmailTemplate = tasks.register("releaseEmailTemplate") + releaseEmailTemplate.configure { + group = "publishing" + description = + "Generate release-vote email subject + body, including the staging repository URL, if run during the Maven release." + + mustRunAfter("initializeApacheStagingRepository") + + doFirst { + val e = project.extensions.getByType(PublishingHelperExtension::class.java) + val asfName = e.asfProjectId.get() + + val gitInfo = MemoizedGitInfo.gitInfo(rootProject) + val gitCommitId = gitInfo["Apache-Polaris-Build-Git-Head"] + + val repos = project.extensions.getByType(NexusPublishExtension::class.java).repositories + val repo = repos.iterator().next() + + val stagingRepositoryUrlRegistryRegistration = + gradle.sharedServices.registrations.named< + BuildServiceRegistration + >( + "stagingRepositoryUrlRegistry" + ) + val staginRepoUrl = + if (stagingRepositoryUrlRegistryRegistration.isPresent) { + val stagingRepositoryUrlRegistryBuildServiceRegistration = + stagingRepositoryUrlRegistryRegistration.get() + val stagingRepositoryUrlRegistryService = + stagingRepositoryUrlRegistryBuildServiceRegistration.getService() + if (stagingRepositoryUrlRegistryService.isPresent) { + val registry = stagingRepositoryUrlRegistryService.get().registry + try { + val stagingRepoDesc = registry.get(repo.name) + val stagingRepoId = stagingRepoDesc.stagingRepositoryId + "https://repository.apache.org/content/repositories/$stagingRepoId/" + } catch (e: IllegalStateException) { + "NO STAGING REPOSITORY ($e)" + } + } else { + "NO STAGING REPOSITORY (no registry service) !!" + } + } else { + "NO STAGING REPOSITORY (no build service) !!" + } + + val asfProjectName = fetchAsfProjectName(asfName) + + val versionNoRc = version.toString().replace("-rc-?[0-9]+".toRegex(), "") + + val subjectFile = e.distributionFile("vote-email-subject.txt").relativeTo(projectDir) + val bodyFile = e.distributionFile("vote-email-body.txt").relativeTo(projectDir) + + val emailSubject = "[VOTE] Release $asfProjectName $version" + subjectFile.writeText(emailSubject) + + val emailBody = + """ + Hi everyone, + + I propose that we release the following RC as the official + $asfProjectName $versionNoRc release. + + * This corresponds to the tag: apache-$asfName-$version + * https://github.com/apache/$asfName/commits/apache-$asfName-$version + * https://github.com/apache/$asfName/tree/$gitCommitId + + The release tarball, signature, and checksums are here: + * https://dist.apache.org/repos/dist/dev/incubator/$asfName/apache-$asfName-$version + + You can find the KEYS file here: + * https://dist.apache.org/repos/dist/release/incubator/$asfName/KEYS + + Convenience binary artifacts are staged on Nexus. The Maven repository URL is: + * $staginRepoUrl + + Please download, verify, and test. + + Please vote in the next 72 hours. + + [ ] +1 Release this as Apache $asfName $version + [ ] +0 + [ ] -1 Do not release this because... + + Only PPMC members and mentors have binding votes, but other community members are + encouraged to cast non-binding votes. This vote will pass if there are + 3 binding +1 votes and more binding +1 votes than -1 votes. + + NB: if this vote pass, a new vote has to be started on the Incubator general mailing + list. + + Thanks + Regards + """ + + logger.lifecycle( + """ + + + The email for your release vote mail: + ------------------------------------- + + Suggested subject: (also in file $subjectFile) + + $emailSubject + + Suggested body: (also in file $bodyFile) + + $emailBody + + """ + .trimIndent() + ) + bodyFile.writeText(emailBody.trimIndent()) + } + } + + if (isRelease) { + sourceTarball.configure { finalizedBy(releaseEmailTemplate) } + } + + afterEvaluate { + tasks.named("closeApacheStagingRepository") { mustRunAfter(releaseEmailTemplate) } + } + } diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/shadowPub.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/shadowPub.kt new file mode 100644 index 0000000..af286fa --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/shadowPub.kt @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import groovy.util.Node +import groovy.util.NodeList +import org.gradle.api.Project +import org.gradle.api.artifacts.ConfigurationVariant +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.LibraryElements +import org.gradle.api.attributes.Usage +import org.gradle.api.component.SoftwareComponentFactory +import org.gradle.api.plugins.JavaBasePlugin +import org.gradle.api.publish.maven.MavenPublication + +/** + * "Proper" publication of shadow-jar instead of the "main" jar, with "the right" Gradle's module + * metadata that refers to the shadow-jar instead of the "main" jar, which is not published by + * Polaris. + * + * Pieces of this function are taken from the `Java(Base)Plugin` and `ShadowExtension`. + */ +internal fun configureShadowPublishing( + project: Project, + mavenPublication: MavenPublication, + softwareComponentFactory: SoftwareComponentFactory, +) = + project.run { + fun isPublishable(element: ConfigurationVariant): Boolean { + for (artifact in element.artifacts) { + if (JavaBasePlugin.UNPUBLISHABLE_VARIANT_ARTIFACTS.contains(artifact.type)) { + return false + } + } + return true + } + + val shadowJar = project.tasks.named("shadowJar") + + val shadowApiElements = + project.configurations.create("shadowApiElements") { + isCanBeConsumed = true + isCanBeResolved = false + attributes { + attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, Usage.JAVA_API)) + attribute( + Category.CATEGORY_ATTRIBUTE, + project.objects.named(Category::class.java, Category.LIBRARY), + ) + attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, + project.objects.named(LibraryElements::class.java, LibraryElements.JAR), + ) + attribute( + Bundling.BUNDLING_ATTRIBUTE, + project.objects.named(Bundling::class.java, Bundling.SHADOWED), + ) + } + outgoing.artifact(shadowJar) + } + + val component = softwareComponentFactory.adhoc("shadow") + component.addVariantsFromConfiguration(shadowApiElements) { + if (isPublishable(configurationVariant)) { + mapToMavenScope("compile") + } else { + skip() + } + } + // component.addVariantsFromConfiguration(configurations.getByName("runtimeElements")) { + component.addVariantsFromConfiguration( + project.configurations.getByName("shadowRuntimeElements") + ) { + if (isPublishable(configurationVariant)) { + mapToMavenScope("runtime") + } else { + skip() + } + } + // Sonatype requires the javadoc and sources jar to be present, but the + // Shadow extension does not publish those. + component.addVariantsFromConfiguration(project.configurations.getByName("javadocElements")) {} + component.addVariantsFromConfiguration(project.configurations.getByName("sourcesElements")) {} + mavenPublication.from(component) + + // This a replacement to add dependencies to the pom, if necessary. Equivalent to + // 'shadowExtension.component(mavenPublication)', which we cannot use. + + mavenPublication.pom { + withXml { + val node = asNode() + val depNode = node.get("dependencies") + val dependenciesNode = + if ((depNode as NodeList).isNotEmpty()) depNode[0] as Node + else node.appendNode("dependencies") + project.configurations.getByName("shadow").allDependencies.forEach { + @Suppress("DEPRECATION") + if ( + (it is ProjectDependency) || it !is org.gradle.api.artifacts.SelfResolvingDependency + ) { + val dependencyNode = dependenciesNode.appendNode("dependency") + dependencyNode.appendNode("groupId", it.group) + dependencyNode.appendNode("artifactId", it.name) + dependencyNode.appendNode("version", it.version) + dependencyNode.appendNode("scope", "runtime") + } + } + } + } + } diff --git a/apprunner/apprunner-build-logic/src/main/kotlin/publishing/util.kt b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/util.kt new file mode 100644 index 0000000..93c14ab --- /dev/null +++ b/apprunner/apprunner-build-logic/src/main/kotlin/publishing/util.kt @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package publishing + +import groovy.json.JsonException +import groovy.json.JsonSlurper +import groovy.util.Node +import groovy.util.NodeList +import java.io.FileNotFoundException +import java.net.URI +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.component.ModuleComponentSelector +import org.gradle.api.artifacts.result.DependencyResult + +internal fun findDependency( + config: Configuration?, + depGroup: String, + depName: String, +): DependencyResult? { + if (config != null) { + val depResult = + config.incoming.resolutionResult.allDependencies.find { depResult -> + val req = depResult.requested + if (req is ModuleComponentSelector) req.group == depGroup && req.module == depName + else false + } + return depResult + } + return null +} + +internal fun xmlNode(node: Node?, child: String): Node? { + val found = node?.get(child) + if (found is NodeList) { + if (found.isNotEmpty()) { + return found[0] as Node + } + } + return null +} + +internal fun unsafeCast(o: Any?): T { + @Suppress("UNCHECKED_CAST") + return o as T +} + +internal fun parseJson(url: String): T { + var attempt = 0 + while (true) { + try { + return unsafeCast(JsonSlurper().parse(URI(url).toURL())) as T + } catch (e: JsonException) { + if (e.cause is FileNotFoundException) { + throw e + } + if (attempt == 5) { + throw e + } + Thread.sleep(1000L) + } + attempt++ + } +} + +internal fun fetchAsfProjectName(apacheId: String): String { + val projectsAll: Map> = + parseJson("https://whimsy.apache.org/public/public_ldap_projects.json") + val projects = unsafeCast>>(projectsAll["projects"]) + val project = + projects[apacheId] + ?: throw IllegalArgumentException( + "No project '$apacheId' found in https://whimsy.apache.org/public/public_ldap_projects.json" + ) + return project["name"] as String +} + +internal fun fetchProjectPeople(apacheId: String): ProjectPeople { + val projectsAll: Map> = + parseJson("https://whimsy.apache.org/public/public_ldap_projects.json") + val projects = unsafeCast>>(projectsAll["projects"]) + val project = + projects[apacheId] + ?: throw IllegalArgumentException( + "No project '$apacheId' found in https://whimsy.apache.org/public/public_ldap_projects.json" + ) + val isPodlingCurrent = project.containsKey("podling") && project["podling"] == "current" + + val inceptionYear = (project["createTimestamp"] as String).subSequence(0, 4).toString().toInt() + + // Committers + val peopleProjectRoles: MutableMap> = mutableMapOf() + val members = unsafeCast(project["members"]) as List + members.forEach { member -> peopleProjectRoles.put(member, mutableListOf("Committer")) } + + // (P)PMC Members + val pmcRoleName = if (isPodlingCurrent) "PPMC member" else "PMC member" + val owners = unsafeCast(project["owners"]) as List + owners.forEach { member -> peopleProjectRoles[member]!!.add(pmcRoleName) } + + val projectName: String + val description: String + val website: String + val repository: String + val licenseUrl: String + val bugDatabase: String + if (isPodlingCurrent) { + val podlingsAll: Map> = + parseJson("https://whimsy.apache.org/public/public_podlings.json") + val podlings = unsafeCast>>(podlingsAll["podling"]) + val podling = + podlings[apacheId] + ?: throw IllegalArgumentException( + "No podling '$apacheId' found in https://whimsy.apache.org/public/public_podlings.json" + ) + projectName = podling["name"] as String + description = podling["description"] as String + val podlingStatus = unsafeCast(podling["podlingStatus"]) as Map + website = podlingStatus["website"] as String + // No repository for podlings?? + repository = "https://github.com/apache/$apacheId.git" + bugDatabase = "https://github.com/apache/$apacheId/issues" + licenseUrl = "https://www.apache.org/licenses/LICENSE-2.0.txt" + + val champion = podling["champion"] as String + peopleProjectRoles[champion]!!.add("Champion") + + val mentors = unsafeCast(podling["mentors"]) as List + mentors.forEach { member -> peopleProjectRoles[member]!!.add("Mentor") } + } else { + val tlpPrj: Map = + parseJson("https://projects.apache.org/json/projects/$apacheId.json") + website = tlpPrj["homepage"] as String + repository = (unsafeCast(tlpPrj["repository"]) as List)[0] + bugDatabase = tlpPrj["bug-database"] as String + licenseUrl = tlpPrj["license"] as String + + val committeesAll: Map> = + parseJson("https://whimsy.apache.org/public/committee-info.json") + val committees = unsafeCast>>(committeesAll["committees"]) + val committee = unsafeCast>(committees[apacheId]) + val pmcChair = unsafeCast>>(committee["chair"]) + projectName = committee["display_name"] as String + description = committee["description"] as String + pmcChair.keys.forEach { chair -> peopleProjectRoles[chair]!!.add("PMC Chair") } + } + + val peopleNames: Map> = + parseJson("https://whimsy.apache.org/public/public_ldap_people.json") + val people: Map> = + unsafeCast(peopleNames["people"]) as Map> + val peopleList = + peopleProjectRoles.entries + .map { entry -> + val person = + people[entry.key] + ?: throw IllegalStateException( + "No person '${entry.key}' found in https://whimsy.apache.org/public/public_ldap_people.json" + ) + ProjectMember(entry.key, person["name"]!! as String, entry.value) + } + .sortedBy { it.name } + + return ProjectPeople( + apacheId, + projectName, + description, + website, + repository, + licenseUrl, + bugDatabase, + inceptionYear, + peopleList, + ) +} + +internal class ProjectPeople( + val apacheId: String, + val name: String, + val description: String, + val website: String, + val repository: String, + val licenseUrl: String, + val bugDatabase: String, + val inceptionYear: Int, + val people: List, +) + +internal class ProjectMember(val apacheId: String, val name: String, val roles: List) diff --git a/apprunner/build.gradle.kts b/apprunner/build.gradle.kts new file mode 100644 index 0000000..f1a53ee --- /dev/null +++ b/apprunner/build.gradle.kts @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.net.URI +import org.nosphere.apache.rat.RatTask + +buildscript { repositories { maven { url = java.net.URI("https://plugins.gradle.org/m2/") } } } + +plugins { + id("idea") + id("eclipse") + id("polaris-apprunner-root") + alias(libs.plugins.rat) +} + +version = rootProject.rootDir.resolve("version.txt").readText().trim() + +publishingHelper { + asfProjectId = "polaris" + overrideName = "Polaris Apprunner" + overrideDescription = "Polaris Apprunner Gradle + Maven plugins" + overrideTagPrefix = "polaris-apprunner" + baseName = "apache-${asfProjectId.get()}-tools-apprunner-${project.version}" + githubRepositoryName = "polaris-tools" +} + +val projectName = rootProject.file("ide-name.txt").readText().trim() +val ideName = "$projectName ${rootProject.version.toString().replace("^([0-9.]+).*", "\\1")}" + +if (System.getProperty("idea.sync.active").toBoolean()) { + // There's no proper way to set the name of the IDEA project (when "just importing" or + // syncing the Gradle project) + val ideaDir = rootProject.layout.projectDirectory.dir(".idea") + ideaDir.asFile.mkdirs() + ideaDir.file(".name").asFile.writeText(ideName) + + val icon = ideaDir.file("icon.png").asFile + if (!icon.exists()) { + copy { + from("docs/img/logos/polaris-brandmark.png") + into(ideaDir) + rename { _ -> "icon.png" } + } + } +} + +eclipse { project { name = ideName } } + +tasks.named("rat").configure { + // These are Gradle file pattern syntax + excludes.add("**/build/**") + + excludes.add("LICENSE") + excludes.add("NOTICE") + + excludes.add("ide-name.txt") + excludes.add("version.txt") + excludes.add(".git") + excludes.add(".gradle") + excludes.add(".idea") + excludes.add(".java-version") + excludes.add("**/.keep") + + excludes.add("gradle/wrapper/gradle-wrapper*.jar*") + + excludes.add("**/*.iml") + excludes.add("**/*.iws") + + excludes.add("**/*.png") + excludes.add("**/*.svg") + + excludes.add("**/*.lock") + + excludes.add("**/*.env*") + + excludes.add("**/kotlin-compiler*") + excludes.add("**/apprunner-build-logic/.kotlin/**") + + excludes.add( + "gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.apache.polaris.apprunner" + ) + excludes.add("maven-plugin/target/**") +} + +// Pass environment variables: +// ORG_GRADLE_PROJECT_apacheUsername +// ORG_GRADLE_PROJECT_apachePassword +// OR in ~/.gradle/gradle.properties set +// apacheUsername +// apachePassword +// Call targets: +// publishToApache +// closeApacheStagingRepository +// releaseApacheStagingRepository +// or closeAndReleaseApacheStagingRepository +// +// Username is your ASF ID +// Password: your ASF LDAP password - or better: a token generated via +// https://repository.apache.org/ +nexusPublishing { + transitionCheckOptions { + // default==60 (10 minutes), wait up to 120 minutes + maxRetries = 720 + // default 10s + // delayBetween = java.time.Duration.ofSeconds(10) + } + + repositories { + register("apache") { + nexusUrl = URI.create("https://repository.apache.org/service/local/") + snapshotRepositoryUrl = + URI.create("https://repository.apache.org/content/repositories/snapshots/") + } + } +} diff --git a/apprunner/codestyle/copyright-header-hash.txt b/apprunner/codestyle/copyright-header-hash.txt new file mode 100644 index 0000000..fe95886 --- /dev/null +++ b/apprunner/codestyle/copyright-header-hash.txt @@ -0,0 +1,18 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# diff --git a/apprunner/codestyle/copyright-header-java.txt b/apprunner/codestyle/copyright-header-java.txt new file mode 100644 index 0000000..042f3ce --- /dev/null +++ b/apprunner/codestyle/copyright-header-java.txt @@ -0,0 +1,18 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ diff --git a/apprunner/codestyle/copyright-header.txt b/apprunner/codestyle/copyright-header.txt new file mode 100644 index 0000000..90705e0 --- /dev/null +++ b/apprunner/codestyle/copyright-header.txt @@ -0,0 +1,16 @@ +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. \ No newline at end of file diff --git a/apprunner/codestyle/copyright-header.xml b/apprunner/codestyle/copyright-header.xml new file mode 100644 index 0000000..af24b97 --- /dev/null +++ b/apprunner/codestyle/copyright-header.xml @@ -0,0 +1,19 @@ + + diff --git a/apprunner/codestyle/errorprone-rules.properties b/apprunner/codestyle/errorprone-rules.properties new file mode 100644 index 0000000..5000e32 --- /dev/null +++ b/apprunner/codestyle/errorprone-rules.properties @@ -0,0 +1,284 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +#################################################################################################### +# On by default : ERROR +# See https://errorprone.info/bugpatterns +#################################################################################################### + +#################################################################################################### +# On by default : WARNING +# See https://errorprone.info/bugpatterns +#################################################################################################### + +AnnotateFormatMethod=ERROR +# This method passes a pair of parameters through to String.format, but the enclosing method wasn't annotated @FormatMethod. Doing so gives compile-time rather than run-time protection against malformed format strings. + +ArrayAsKeyOfSetOrMap=ERROR +# Arrays do not override equals() or hashCode, so comparisons will be done on reference equality only. If neither deduplication nor lookup are needed, consider using a List instead. Otherwise, use IdentityHashMap/Set, a Map from a library that handles object arrays, or an Iterable/List of pairs. + +AssertEqualsArgumentOrderChecker=ERROR +# Arguments are swapped in assertEquals-like call + +AssertThrowsMultipleStatements=ERROR +# The lambda passed to assertThrows should contain exactly one statement + +AssertionFailureIgnored=ERROR +# This assertion throws an AssertionError if it fails, which will be caught by an enclosing try block. + +BadImport=ERROR +# Importing nested classes/static methods/static fields with commonly-used names can make code harder to read, because it may not be clear from the context exactly which type is being referred to. Qualifying the name with that of the containing class can make the code clearer. + +BadInstanceof=ERROR +# instanceof used in a way that is equivalent to a null check. + +BareDotMetacharacter=ERROR +# "." is rarely useful as a regex, as it matches any character. To match a literal '.' character, instead write "\.". + +BigDecimalEquals=ERROR +# BigDecimal#equals has surprising behavior: it also compares scale. + +BigDecimalLiteralDouble=ERROR +# new BigDecimal(double) loses precision in this case. + +BoxedPrimitiveConstructor=ERROR +# valueOf or autoboxing provides better time and space performance + +ByteBufferBackingArray=ERROR +# ByteBuffer.array() shouldn't be called unless ByteBuffer.arrayOffset() is used or if the ByteBuffer was initialized using ByteBuffer.wrap() or ByteBuffer.allocate(). + +CanIgnoreReturnValueSuggester=OFF +# Methods that always 'return this' should be annotated with @CanIgnoreReturnValue + +CatchAndPrintStackTrace=ERROR +# Logging or rethrowing exceptions should usually be preferred to catching and calling printStackTrace + +ClassCanBeStatic=ERROR +# Inner class is non-static but does not reference enclosing class + +ClassNewInstance=ERROR +# Class.newInstance() bypasses exception checking; prefer getDeclaredConstructor().newInstance() + +DateFormatConstant=ERROR +# DateFormat is not thread-safe, and should not be used as a constant field. + +DefaultCharset=ERROR +# Implicit use of the platform default charset, which can result in differing behaviour between JVM executions or incorrect behavior if the encoding of the data source doesn't match expectations. + +DistinctVarargsChecker=ERROR +# Method expects distinct arguments at some/all positions + +DoubleCheckedLocking=ERROR +# Double-checked locking on non-volatile fields is unsafe + +# TODO enable: EqualsGetClass=ERROR +# Prefer instanceof to getClass when implementing Object#equals. + +EqualsIncompatibleType=ERROR +# An equality test between objects with incompatible types always returns false + +EqualsUnsafeCast=ERROR +# The contract of #equals states that it should return false for incompatible types, while this implementation may throw ClassCastException. + +EqualsUsingHashCode=ERROR +# Implementing #equals by just comparing hashCodes is fragile. Hashes collide frequently, and this will lead to false positives in #equals. + +ErroneousBitwiseExpression=ERROR +# This expression evaluates to 0. If this isn't an error, consider expressing it as a literal 0. + +ErroneousThreadPoolConstructorChecker=ERROR +# Thread pool size will never go beyond corePoolSize if an unbounded queue is used + +EscapedEntity=ERROR +# HTML entities in @code/@literal tags will appear literally in the rendered javadoc. + +FallThrough=ERROR +# Switch case may fall through + +FloatCast=ERROR +# Use parentheses to make the precedence explicit + +FloatingPointAssertionWithinEpsilon=ERROR +# This fuzzy equality check is using a tolerance less than the gap to the next number. You may want a less restrictive tolerance, or to assert equality. + +FloatingPointLiteralPrecision=ERROR +# Floating point literal loses precision + +FutureReturnValueIgnored=ERROR +# Return value of methods returning Future must be checked. Ignoring returned Futures suppresses exceptions thrown from the code that completes the Future. + +GetClassOnEnum=ERROR +# Calling getClass() on an enum may return a subclass of the enum type + +InconsistentHashCode=ERROR +# Including fields in hashCode which are not compared in equals violates the contract of hashCode. + +IntLongMath=ERROR +# Expression of type int may overflow before being assigned to a long + +JavaLangClash=ERROR +# Never reuse class names from java.lang + +JdkObsolete=ERROR +# Suggests alternatives to obsolete JDK classes. + +LockNotBeforeTry=ERROR +# Calls to Lock#lock should be immediately followed by a try block which releases the lock. + +LongDoubleConversion=ERROR +# Conversion from long to double may lose precision; use an explicit cast to double if this was intentional + +LongFloatConversion=ERROR +# Conversion from long to float may lose precision; use an explicit cast to float if this was intentional + +MissingCasesInEnumSwitch=ERROR +# Switches on enum types should either handle all values, or have a default case. + +MissingOverride=ERROR +# method overrides method in supertype; expected @Override + +ModifiedButNotUsed=ERROR +# A collection or proto builder was created, but its values were never accessed. + +MockNotUsedInProduction=ERROR +# This mock is instantiated and configured, but is never passed to production code. It should be +# either removed or used. + +NonAtomicVolatileUpdate=ERROR +# This update of a volatile variable is non-atomic + +NonCanonicalType=ERROR +# This type is referred to by a non-canonical name, which may be misleading. + +NotJavadoc=ERROR +# Avoid using /** for comments which aren't actually Javadoc. + +NullOptional=ERROR +# Passing a literal null to an Optional parameter is almost certainly a mistake. Did you mean to provide an empty Optional? + +ObjectEqualsForPrimitives=ERROR +# Avoid unnecessary boxing by using plain == for primitive types. + +OperatorPrecedence=ERROR +# Use grouping parenthesis to make the operator precedence explicit + +OrphanedFormatString=ERROR +# String literal contains format specifiers, but is not passed to a format method + +Overrides=ERROR +# Varargs doesn't agree for overridden method + +# TODO PatternMatchingInstanceof=ERROR +# This code can be simplified to use a pattern-matching instanceof. + +StreamToIterable=ERROR +# Using stream::iterator creates a one-shot Iterable, which may cause surprising failures. + +SynchronizeOnNonFinalField=ERROR +# Synchronizing on non-final fields is not safe: if the field is ever updated, different threads may end up locking on different objects. + +ThreadLocalUsage=ERROR +# ThreadLocals should be stored in static fields + +URLEqualsHashCode=ERROR +# Avoid hash-based containers of java.net.URL–the containers rely on equals() and hashCode(), which cause java.net.URL to make blocking internet connections. + +UnnecessaryLambda=ERROR +# Returning a lambda from a helper method or saving it in a constant is unnecessary; prefer to implement the functional interface method directly and use a method reference instead. + +# TODO enable: UnusedMethod=ERROR +# Unused. + +UnusedNestedClass=ERROR +# This nested class is unused, and can be removed. + +UnusedTypeParameter=ERROR +# This type parameter is unused and can be removed. + +UseCorrectAssertInTests=ERROR +# Java assert is used in test. For testing purposes Assert.* matchers should be used. + +#################################################################################################### +# Experimental : ERROR +# See https://errorprone.info/bugpatterns +#################################################################################################### + +#################################################################################################### +# Experimental : WARNING +# See https://errorprone.info/bugpatterns +#################################################################################################### + +ConstantPatternCompile=ERROR +# Variables initialized with Pattern#compile calls on constants can be constants + +PrimitiveArrayPassedToVarargsMethod=ERROR +# Passing a primitive array to a varargs method is usually wrong + +RedundantOverride=ERROR +# This overriding method is redundant, and can be removed. + +RedundantThrows=ERROR +# Thrown exception is a subtype of another + +StringCaseLocaleUsage=ERROR +# Specify a `Locale` when calling `String#to{Lower,Upper}Case`. (Note: there are multiple suggested fixes; the third may be most appropriate if you're dealing with ASCII Strings.) + +StronglyTypeByteString=WARN +# This primitive byte array is only used to construct ByteStrings. It would be clearer to strongly type the field instead. + +StronglyTypeTime=ERROR +# This primitive integral type is only used to construct time types. It would be clearer to strongly type the field instead. + +TestExceptionChecker=ERROR +# Using @Test(expected=…) is discouraged, since the test will pass if any statement in the test method throws the expected exception + +TransientMisuse=ERROR +# Static fields are implicitly transient, so the explicit modifier is unnecessary + +UrlInSee=ERROR +# URLs should not be used in @see tags; they are designed for Java elements which could be used with @link. + +#################################################################################################### +# Experimental : SUGGESTION +# See https://errorprone.info/bugpatterns +#################################################################################################### + +FieldCanBeStatic=ERROR +# A final field initialized at compile-time with an instance of an immutable type can be static. + +ForEachIterable=ERROR +# This loop can be replaced with an enhanced for loop. + +MixedArrayDimensions=ERROR +# C-style array declarations should not be used + +PackageLocation=ERROR +# Package names should match the directory they are declared in + +TryFailRefactoring=ERROR +# Prefer assertThrows to try/fail + +UnnecessaryBoxedAssignment=WARN +# This expression can be implicitly boxed. + +UnnecessaryBoxedVariable=ERROR +# It is unnecessary for this variable to be boxed. Use the primitive instead. + +UseEnumSwitch=ERROR +# Prefer using a switch instead of a chained if-else for enums diff --git a/apprunner/codestyle/org.eclipse.wst.xml.core.prefs b/apprunner/codestyle/org.eclipse.wst.xml.core.prefs new file mode 100644 index 0000000..4a9500c --- /dev/null +++ b/apprunner/codestyle/org.eclipse.wst.xml.core.prefs @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# + +eclipse.preferences.version=1 +formatCommentJoinLines=false +formatCommentText=false +indentationChar=space +indentationSize=2 +lineWidth=100 +spaceBeforeEmptyCloseTag=false diff --git a/apprunner/common/build.gradle.kts b/apprunner/common/build.gradle.kts new file mode 100644 index 0000000..89ca14e --- /dev/null +++ b/apprunner/common/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +plugins { id("polaris-apprunner-java") } + +dependencies { compileOnly(libs.jakarta.annotation.api) } diff --git a/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/InputBuffer.java b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/InputBuffer.java new file mode 100644 index 0000000..8e21fa8 --- /dev/null +++ b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/InputBuffer.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.util.function.Consumer; + +/** Captures input from an {@link InputStream} and emits full lines terminated with a {@code LF}. */ +final class InputBuffer { + + private final Reader input; + private final Consumer output; + private final StringBuilder lineBuffer = new StringBuilder(); + private boolean failed; + + InputBuffer(InputStream input, Consumer output) { + this(new BufferedReader(new InputStreamReader(input, Charset.defaultCharset())), output); + } + + InputBuffer(Reader input, Consumer output) { + this.input = input; + this.output = output; + } + + /** + * Drains the input passed to the constructor until there's no more data to read, captures full + * lines terminated with a {@code LF}) and pushes these lines to the consumer passed into the + * constructor. + * + * @return {@code true} if any data has been read from the input stream + */ + boolean io() { + // Note: cannot use BufferedReader.readLine() here, because that would block. + try { + if (failed || !input.ready()) { + return false; + } + + var any = false; + while (input.ready()) { + var c = input.read(); + + if (c == -1) { + return any; + } + + any = true; + switch (c) { + case 13 -> { // CR + } + case 10 -> { // LF + output.accept(lineBuffer.toString()); + lineBuffer.setLength(0); + } + default -> { + lineBuffer.append((char) c); + } + } + } + return true; + } catch (IOException e) { + e.printStackTrace(); + failed = true; + return false; + } + } + + void flush() { + if (!lineBuffer.isEmpty()) { + output.accept(lineBuffer.toString()); + lineBuffer.setLength(0); + } + } +} diff --git a/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/JavaVM.java b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/JavaVM.java new file mode 100644 index 0000000..bfbd3dc --- /dev/null +++ b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/JavaVM.java @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.regex.Pattern; + +/** + * Helper class to locate a JDK by Java major version and return the path to the {@code java} + * executable. + */ +public final class JavaVM { + + public static final int MAX_JAVA_VERSION_TO_CHECK = 19; + + private static final Pattern MAJOR_VERSION_PATTERN = Pattern.compile("^(1[.])?([0-9]+)([.].+)?$"); + + private static final AtomicReference CURRENT_JVM = new AtomicReference<>(); + private final Path javaHome; + + static String locateJavaHome( + int majorVersion, + Function getenv, + Function getProperty, + IntFunction macosHelper) { + + var home = getenv.apply(String.format("JDK%d_HOME", majorVersion)); + if (home != null) { + return home; + } + home = getenv.apply(String.format("JAVA%d_HOME", majorVersion)); + if (home != null) { + return home; + } + + home = getProperty.apply(String.format("jdk%d.home", majorVersion)); + if (home != null) { + return home; + } + home = getProperty.apply(String.format("java%d.home", majorVersion)); + if (home != null) { + return home; + } + + if (getProperty.apply("os.name").toLowerCase(Locale.ROOT).contains("darwin")) { + return macosHelper.apply(majorVersion); + } + + return null; + } + + /** + * Loops from {@code majorVersion} up to {@value #MAX_JAVA_VERSION_TO_CHECK} until a call to + * {@link #findJavaVMTryExact(int)} returns a Java-Home. + * + *

Returns the current JVM from {@link #getCurrentJavaVM()}, if its major version is greater + * than or equal to the requested {@code majorVersion}. + * + * @param majorVersion the Java major-version to start with. + * @return a Java-VM on the local system or {@code null}, if no matching Java-Home could be found. + */ + public static JavaVM findJavaVM(int majorVersion) { + if (currentJavaVMMajorVersion() >= majorVersion) { + return getCurrentJavaVM(); + } + for (var i = majorVersion; i < MAX_JAVA_VERSION_TO_CHECK; i++) { + var jvm = findJavaVMTryExact(i); + if (jvm != null) { + return jvm; + } + } + return null; + } + + /** + * Find a Java-Home that exactly matches the given Java major version. + * + *

Searches for the Java-Home in these places in this exact order: + * + *

    + *
  1. Environment variable {@code JDKxx_HOME}, where {@code xx} is the {@code majorVersion}. + *
  2. Environment variable {@code JAVAxx_HOME}, where {@code xx} is the {@code majorVersion}. + *
  3. System property {@code jdkXX.home}, where {@code XX} is the {@code majorVersion}. + *
  4. System property {@code javaXX.home}, where {@code XX} is the {@code majorVersion}. + *
  5. Using the {@code /usr/libexec/java_home} on MacOS, which may return a newer Java version. + *
+ * + * @param majorVersion the Java major-version to search for. + * @return a Java-VM on the local system or {@code null}, if no matching Java-Home could be found. + */ + public static JavaVM findJavaVMTryExact(int majorVersion) { + if (majorVersion == currentJavaVMMajorVersion()) { + return getCurrentJavaVM(); + } + + var home = + locateJavaHome( + majorVersion, + System::getenv, + System::getProperty, + ver -> { + try { + String versionArg = ver < 9 ? ("1." + ver) : Integer.toString(ver); + Process proc = + new ProcessBuilder() + .command("/usr/libexec/java_home", "-v", versionArg) + .start(); + return new BufferedReader( + new InputStreamReader(proc.getInputStream(), Charset.defaultCharset())) + .readLine(); + } catch (IOException e) { + return null; + } + }); + if (home != null) { + return forJavaHome(home); + } + return null; + } + + /** + * Get the {@link JavaVM} instance for the current JVM. + * + * @return {@link JavaVM} instance for the current JVM, never {@code null}. + */ + public static JavaVM getCurrentJavaVM() { + var current = CURRENT_JVM.get(); + if (current == null) { + CURRENT_JVM.set(current = forJavaHome(Paths.get(System.getProperty("java.home")))); + } + return current; + } + + public static int currentJavaVMMajorVersion() { + return majorVersionFromString(System.getProperty("java.version")); + } + + /** + * Extracts the major version from a Java-version-string as returned from {@code + * System.getProperty("java.version)}. + * + * @param versionString the Java-version-string + * @return extracted Java major version + */ + public static int majorVersionFromString(String versionString) { + var m = MAJOR_VERSION_PATTERN.matcher(versionString); + if (!m.matches()) { + throw new IllegalArgumentException( + String.format("%s is not a valid Java version string", versionString)); + } + return Integer.parseInt(m.group(2)); + } + + static Path fixJavaHome(Path javaHome) { + if ("jre".equals(javaHome.getFileName().toString()) + && Files.isExecutable(javaHome.resolve("bin").resolve(executableName("java")))) { + var check = javaHome.getParent(); + if (Files.isExecutable(check.resolve("bin").resolve(executableName("java")))) { + javaHome = check; + } + } + return javaHome; + } + + public static JavaVM forJavaHome(String javaHome) { + return forJavaHome(Paths.get(javaHome)); + } + + public static JavaVM forJavaHome(Path javaHome) { + return new JavaVM(fixJavaHome(javaHome)); + } + + private JavaVM(Path javaHome) { + this.javaHome = javaHome; + } + + public Path getJavaHome() { + return javaHome; + } + + public Path getJavaExecutable() { + return getExecutable("java"); + } + + private Path getExecutable(String executable) { + return javaHome.resolve("bin").resolve(executableName(executable)); + } + + static String executableName(String executable) { + if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) { + return executable + ".exe"; + } + return executable; + } +} diff --git a/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ListenUrlWaiter.java b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ListenUrlWaiter.java new file mode 100644 index 0000000..9fd38a6 --- /dev/null +++ b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ListenUrlWaiter.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import java.util.regex.Pattern; + +/** + * Accepts {@link String}s via it's {@link #accept(String)} method and checks for the {@code + * Listening on: http...} pattern. + */ +final class ListenUrlWaiter implements Consumer { + + private static final Pattern HTTP_PORT_LOG_PATTERN = + Pattern.compile( + "^.*Listening on: (http[s]?://[^ ]*)([.] Management interface listening on (http[s]?://[^ ]*)[.])?$"); + static final String TIMEOUT_MESSAGE = + "Did not get the http(s) listen URL from the console output."; + private static final long MAX_ITER_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(50); + public static final String NOTHING_RECEIVED = " No output received from process."; + public static final String CAPTURED_LOG_FOLLOWS = " Captured output follows:\n"; + + private final LongSupplier clock; + private final Consumer stdoutTarget; + private final long deadlineListenUrl; + + private final CompletableFuture> listenUrl = new CompletableFuture<>(); + private final List capturedLog = new ArrayList<>(); + + /** + * Construct a new instance to wait for Quarkus' {@code Listening on: ...} message. + * + * @param clock monotonic clock, nanoseconds + * @param timeToListenUrlMillis timeout in millis, the "Listen on: ..." must be received within + * this time (otherwise it will fail) + * @param stdoutTarget "real" target for "stdout" + */ + ListenUrlWaiter(LongSupplier clock, long timeToListenUrlMillis, Consumer stdoutTarget) { + this.clock = clock; + this.stdoutTarget = stdoutTarget; + this.deadlineListenUrl = + clock.getAsLong() + TimeUnit.MILLISECONDS.toNanos(timeToListenUrlMillis); + } + + @Override + public void accept(String line) { + if (!listenUrl.isDone()) { + synchronized (capturedLog) { + capturedLog.add(line); + var m = HTTP_PORT_LOG_PATTERN.matcher(line); + if (m.matches()) { + listenUrl.complete(Arrays.asList(m.group(1), m.group(3))); + capturedLog.clear(); + } + } + } + stdoutTarget.accept(line); + } + + List peekListenUrls() { + try { + return listenUrl.isDone() ? listenUrl.get() : null; + } catch (Exception e) { + throw new RuntimeException(); + } + } + + /** + * Get the first captured {@code Listening on: http...} pattern. + * + * @return the captured listen URL or {@code null} if none has been found (so far). + */ + List getListenUrls() throws InterruptedException, TimeoutException { + while (true) { + var remainingNanos = remainingNanos(); + // must succeed if the listen-url has been captured, even if it's called after the timeout has + // elapsed + if (remainingNanos < 0 && !listenUrl.isDone()) { + throw getTimeoutException(null); + } + + try { + return listenUrl.get(Math.min(MAX_ITER_WAIT_NANOS, remainingNanos), TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + // Continue, check above. + // This "short get()" is implemented to make the unit test TestListenUrlWaiter.noTimeout() + // run faster. + } catch (ExecutionException e) { + if (e.getCause() instanceof TimeoutException) { + throw getTimeoutException(e.getCause()); + } + if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } else { + throw new RuntimeException(e.getCause()); + } + } + } + } + + private TimeoutException getTimeoutException(Throwable cause) { + String log; + synchronized (capturedLog) { + log = String.join("\n", capturedLog); + } + var ex = + new TimeoutException( + TIMEOUT_MESSAGE + (log.isEmpty() ? NOTHING_RECEIVED : (CAPTURED_LOG_FOLLOWS + log))); + if (cause != null) { + ex.addSuppressed(cause); + } + return ex; + } + + void stopped(String reason) { + listenUrl.completeExceptionally(new RuntimeException(reason)); + } + + void timedOut() { + listenUrl.completeExceptionally(new TimeoutException()); + } + + public void exited(int exitCode) { + // No-op, if the listen-URL has already been received, so using the TIMEOUT_MESSAGE here is + // fine. + String log; + synchronized (capturedLog) { + log = String.join("\n", capturedLog); + } + listenUrl.completeExceptionally( + new RuntimeException( + ListenUrlWaiter.TIMEOUT_MESSAGE + + " Process exited early, exit code is " + + exitCode + + "." + + (log.isEmpty() ? NOTHING_RECEIVED : (CAPTURED_LOG_FOLLOWS + log)))); + } + + long remainingNanos() { + return deadlineListenUrl - clock.getAsLong(); + } + + boolean isTimeout() { + if (listenUrl.isDone() && !listenUrl.isCompletedExceptionally()) { + return false; + } + return remainingNanos() < 0; + } +} diff --git a/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ProcessHandler.java b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ProcessHandler.java new file mode 100644 index 0000000..0dd9bcf --- /dev/null +++ b/apprunner/common/src/main/java/org/apache/polaris/apprunner/common/ProcessHandler.java @@ -0,0 +1,294 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** + * Handles the execution of an external process, focused on running a Quarkus application jar. + * + *

Starts the process configured in a {@link ProcessBuilder}, provides a method to get the {@link + * #getListenUrls() Quarkus HTTP listen URL} as Quarkus prints to stdout, and manages process + * lifetime and line-by-line I/O pass-through for stdout + stderr. + * + *

Any instance of this class can only be used to start (and stop) one process and cannot be + * reused for another process. + * + *

This implementation is not thread-safe. + */ +public class ProcessHandler { + + // intentionally long timeouts - think: slow CI systems + public static final long MILLIS_TO_HTTP_PORT = 30_000L; + public static final long MILLIS_TO_STOP = 15_000L; + + private LongSupplier ticker = System::nanoTime; + + private static final int NOT_STARTED = -1; + private static final int RUNNING = -2; + private static final int ERROR = -3; + private final AtomicInteger exitCode = new AtomicInteger(NOT_STARTED); + + private final AtomicBoolean stopped = new AtomicBoolean(); + + private Process process; + + private long timeToListenUrlMillis = MILLIS_TO_HTTP_PORT; + private long timeStopMillis = MILLIS_TO_STOP; + + private Consumer stdoutTarget = System.out::println; + private ListenUrlWaiter listenUrlWaiter; + + private volatile ExecutorService watchdogExecutor; + private volatile Future watchdogFuture; + private volatile Thread shutdownHook; + + public ProcessHandler() { + // empty + } + + public ProcessHandler setTimeToListenUrlMillis(long timeToListenUrlMillis) { + this.timeToListenUrlMillis = timeToListenUrlMillis; + return this; + } + + public ProcessHandler setTimeStopMillis(long timeStopMillis) { + this.timeStopMillis = timeStopMillis; + return this; + } + + public ProcessHandler setStdoutTarget(Consumer stdoutTarget) { + this.stdoutTarget = stdoutTarget; + return this; + } + + public ProcessHandler setTicker(LongSupplier ticker) { + this.ticker = ticker; + return this; + } + + /** + * Starts the process from the given {@link ProcessBuilder}. + * + * @param processBuilder process to start + * @return instance handling the process' runtime + * @throws IOException usually, if the process fails to start + */ + public ProcessHandler start(ProcessBuilder processBuilder) throws IOException { + if (process != null) { + throw new IllegalStateException("Process already started"); + } + + return started(processBuilder.redirectErrorStream(true).start()); + } + + /** + * Alternative to {@link #start(ProcessBuilder)}, directly configures a running process. + * + * @param process running process + * @return {@code this} + */ + ProcessHandler started(Process process) { + if (this.process != null) { + throw new IllegalStateException("Process already started"); + } + + listenUrlWaiter = new ListenUrlWaiter(ticker, timeToListenUrlMillis, stdoutTarget); + + this.process = process; + exitCode.set(RUNNING); + + shutdownHook = new Thread(this::shutdownHandler); + Runtime.getRuntime().addShutdownHook(shutdownHook); + + watchdogExecutor = Executors.newSingleThreadExecutor(); + watchdogFuture = watchdogExecutor.submit(this::watchdog); + + return this; + } + + /** + * Returns the http(s) listen URL as a string as emitted to stdout by Quarkus. + * + *

If the Quarkus process does not emit that URL within the time configured via {@link + * #setTimeToListenUrlMillis(long)}, which defaults to {@value #MILLIS_TO_HTTP_PORT} ms, this + * method will throw an {@link IllegalStateException}. + * + * @return the listen URL, never {@code null}. + * @throws InterruptedException if the current thread was interrupted while waiting for the listen + * URL. + * @throws TimeoutException if the Quarkus process did not write the listen URL to stdout. + */ + public List getListenUrls() throws InterruptedException, TimeoutException { + return listenUrlWaiter.getListenUrls(); + } + + /** + * Stops the process. + * + *

Tries to gracefully stop the process via a {@code SIGTERM}. If the process is still alive + * after {@link #setTimeStopMillis(long)}, which defaults to {@value #MILLIS_TO_STOP} ms, the + * process will be killed with a {@code SIGKILL}. + */ + public void stop() { + if (process == null) { + throw new IllegalStateException("No process started"); + } + + doStop("Stopped by plugin"); + + watchdogExitGrace(); + } + + private void shutdownHandler() { + doStop("Stop by shutdown handler"); + } + + private void doStop(String reason) { + if (stopped.compareAndSet(false, true)) { + try { + if (reason != null) { + listenUrlWaiter.stopped(reason); + } else { + listenUrlWaiter.timedOut(); + } + process.destroy(); + try { + if (!process.waitFor(timeStopMillis, TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + watchdogExecutor.shutdown(); + } finally { + try { + // Don't remove the shutdown-hook if we're running in the shutdown-hook + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException e) { + // ignore (might happen, when a JVM shutdown is already in progress) + } + } + } + } + + void watchdogExitGrace() { + try { + // Give the watchdog task/thread some time to finish its work + watchdogFuture.get(timeStopMillis, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + throw new RuntimeException("ProcessHandler's watchdog thread failed.", e); + } catch (TimeoutException e) { + throw new IllegalStateException("ProcessHandler's watchdog thread failed to finish in time."); + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + + public boolean isAlive() { + return exitCode.get() == RUNNING; + } + + /** + * Retrieves the exit-code of the process, if it terminated or throws a {@link + * IllegalThreadStateException} if it is still alive. + * + * @return the exit code of the process + * @throws IllegalThreadStateException if the process is still alive + */ + public int getExitCode() throws IllegalThreadStateException { + if (isAlive()) { + throw new IllegalThreadStateException(); + } + return exitCode.get(); + } + + long remainingWaitTimeNanos() { + return listenUrlWaiter.remainingNanos(); + } + + private Object watchdog() throws IOException { + try (var out = process.getInputStream()) { + var stdout = new InputBuffer(out, listenUrlWaiter); + try { + + /* + * I/O loop. + * + * Fetches data from stdout + stderr and pushes the read data to the associated `InputBuffer` + * instances. The one for `stdout` listens for the HTTP listen address from Quarkus. + * + * As long as there is data from stdout or stderr, the loop does not wait/sleep to get data + * out as fast as possible. If there's no data available, the loop will "yield" via a + * Thread.sleep(1L), which is good enough. + * + * Note: we cannot do blocking-I/O here, because we have to read from both stdout+stderr. + * + * If the process exits, the loop will exit as soon as there is no more data left from + * stdout/stderr. + */ + while (true) { + var anyIo = stdout.io(); + + try { + var ec = process.exitValue(); + exitCode.set(ec); + if (!anyIo) { + listenUrlWaiter.exited(exitCode.get()); + break; + } + } catch (IllegalThreadStateException e) { + // server still alive + } + + if (listenUrlWaiter.isTimeout() && !stopped.get()) { + doStop(null); + } + + if (!anyIo) { + try { + // Yield CPU for a little while, so this background thread does not consume 100% CPU. + Thread.sleep(1L); + } catch (InterruptedException interruptedException) { + doStop("ProcessHandler's watchdog thread interrupted."); + exitCode.set(ERROR); + break; + } + } + } + } finally { + stdout.flush(); + } + } + return null; + } +} diff --git a/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestInputBuffer.java b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestInputBuffer.java new file mode 100644 index 0000000..9ca258a --- /dev/null +++ b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestInputBuffer.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.concurrent.ArrayBlockingQueue; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +class TestInputBuffer { + @InjectSoftAssertions SoftAssertions soft; + + @Test + void emptyInput() { + var lines = new ArrayList(); + var buf = new InputBuffer(new StringReader(""), lines::add); + soft.assertThat(buf.io()).isFalse(); + soft.assertThat(lines).isEmpty(); + buf.flush(); + soft.assertThat(lines).isEmpty(); + } + + @Test + void scattered() { + var characters = new ArrayBlockingQueue(250); + + // Just need some Reader implementation that implements ready() + read() + var reader = + new StringReader("") { + @Override + public int read() { + return characters.poll(); + } + + @Override + public boolean ready() { + return characters.size() > 0; + } + }; + + var lines = new ArrayList(); + var buf = new InputBuffer(reader, lines::add); + soft.assertThat(buf.io()).isFalse(); + soft.assertThat(lines).isEmpty(); + + for (var c : "Hello World".toCharArray()) { + characters.add((int) c); + } + + // It should have done some I/O ... + soft.assertThat(buf.io()).isTrue(); + // ... but there was no trailing newline, so nothing to print (yet) + soft.assertThat(lines).isEmpty(); + + for (var c : "\nFoo Bar Baz\nMeep".toCharArray()) { + characters.add((int) c); + } + + // It should have done some I/O ... + soft.assertThat(buf.io()).isTrue(); + // ... and give us the first two lines ("Meep" is on an unterminated line) + soft.assertThat(lines).containsExactly("Hello World", "Foo Bar Baz"); + + // Just a CR does not trigger a "line complete" + characters.add(13); + soft.assertThat(buf.io()).isTrue(); + soft.assertThat(lines).containsExactly("Hello World", "Foo Bar Baz"); + + // ... but a LF does + characters.add(10); + soft.assertThat(buf.io()).isTrue(); + soft.assertThat(lines).containsExactly("Hello World", "Foo Bar Baz", "Meep"); + + // Add some more data, with an unterminated line... + for (char c : "\nMore text\nNo EOL".toCharArray()) { + characters.add((int) c); + } + soft.assertThat(buf.io()).isTrue(); + soft.assertThat(lines).containsExactly("Hello World", "Foo Bar Baz", "Meep", "", "More text"); + + // "Final" flush() should yield the remaining data + buf.flush(); + soft.assertThat(lines) + .containsExactly("Hello World", "Foo Bar Baz", "Meep", "", "More text", "No EOL"); + } +} diff --git a/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestJavaVM.java b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestJavaVM.java new file mode 100644 index 0000000..2f8e95b --- /dev/null +++ b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestJavaVM.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.HashMap; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(SoftAssertionsExtension.class) +class TestJavaVM { + @InjectSoftAssertions SoftAssertions soft; + + @Test + void checkJavaVersionStrings() { + soft.assertThat(JavaVM.majorVersionFromString("11")).isEqualTo(11); + soft.assertThat(JavaVM.majorVersionFromString("17.0.1")).isEqualTo(17); + soft.assertThat(JavaVM.majorVersionFromString("1.8.0-foo+bar")).isEqualTo(8); + } + + @Test + void checkResolveEnvJdkHomeLinux() { + var env = new HashMap(); + env.put("JDK11_HOME", "/mycomputer/java11"); + env.put("JAVA17_HOME", "/mycomputer/java17"); + + var sysProps = new HashMap(); + sysProps.put("jdk9.home", "/mycomputer/java9"); + sysProps.put("java10.home", "/mycomputer/java10"); + sysProps.put("os.name", "Linux"); + + soft.assertThat(JavaVM.locateJavaHome(8, env::get, sysProps::get, i -> "/hello/there")) + .isNull(); + soft.assertThat(JavaVM.locateJavaHome(9, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java9"); + soft.assertThat(JavaVM.locateJavaHome(10, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java10"); + soft.assertThat(JavaVM.locateJavaHome(11, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java11"); + soft.assertThat(JavaVM.locateJavaHome(14, env::get, sysProps::get, i -> "/hello/there")) + .isNull(); + soft.assertThat(JavaVM.locateJavaHome(17, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java17"); + } + + @Test + void checkResolveEnvJdkHomeMacOS() { + var env = new HashMap(); + env.put("JDK11_HOME", "/mycomputer/java11"); + env.put("JAVA17_HOME", "/mycomputer/java17"); + + var sysProps = new HashMap(); + sysProps.put("jdk9.home", "/mycomputer/java9"); + sysProps.put("java10.home", "/mycomputer/java10"); + sysProps.put("os.name", "Darwin"); + + soft.assertThat( + JavaVM.locateJavaHome( + 8, env::get, sysProps::get, i -> i == 8 ? "/from_java_home/v8" : null)) + .isEqualTo("/from_java_home/v8"); + soft.assertThat(JavaVM.locateJavaHome(9, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java9"); + soft.assertThat(JavaVM.locateJavaHome(10, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java10"); + soft.assertThat(JavaVM.locateJavaHome(11, env::get, sysProps::get, i -> "/hello/there")) + .isEqualTo("/mycomputer/java11"); + soft.assertThat( + JavaVM.locateJavaHome( + 14, env::get, sysProps::get, i -> i >= 12 ? "/from_java_home/v16" : null)) + .isEqualTo("/from_java_home/v16"); + soft.assertThat( + JavaVM.locateJavaHome( + 17, env::get, sysProps::get, i -> i >= 12 ? "/from_java_home/v8" : null)) + .isEqualTo("/mycomputer/java17"); + } + + @Test + void checkJreResolve(@TempDir Path jdkDir) throws Exception { + var jdkBinDir = jdkDir.resolve("bin"); + var jdkJavaFile = jdkBinDir.resolve(JavaVM.executableName("java")); + var jreDir = jdkDir.resolve("jre"); + var jreBinDir = jreDir.resolve("bin"); + var jreJavaFile = jreBinDir.resolve(JavaVM.executableName("java")); + + Files.createDirectories(jdkBinDir); + Files.createDirectories(jreBinDir); + Files.createFile( + jreJavaFile, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---"))); + + var env = new HashMap(); + env.put("JDK8_HOME", jreDir.toString()); + + var sysProps = new HashMap(); + sysProps.put("os.name", "Linux"); + + soft.assertThat(JavaVM.locateJavaHome(8, env::get, sysProps::get, x -> null)) + .isEqualTo(jreDir.toString()); + soft.assertThat(JavaVM.fixJavaHome(jreDir)).isEqualTo(jreDir); + soft.assertThat(JavaVM.forJavaHome(jreDir).getJavaExecutable()).isEqualTo(jreJavaFile); + soft.assertThat(JavaVM.forJavaHome(jreDir).getJavaHome()).isEqualTo(jreDir); + soft.assertThat(JavaVM.forJavaHome(jreDir.toString()).getJavaHome()).isEqualTo(jreDir); + + Files.createFile( + jdkJavaFile, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---"))); + + soft.assertThat(JavaVM.fixJavaHome(jreDir)).isEqualTo(jdkDir); + soft.assertThat(JavaVM.forJavaHome(jreDir).getJavaExecutable()).isEqualTo(jdkJavaFile); + soft.assertThat(JavaVM.forJavaHome(jreDir).getJavaHome()).isEqualTo(jdkDir); + soft.assertThat(JavaVM.forJavaHome(jreDir.toString()).getJavaHome()).isEqualTo(jdkDir); + } +} diff --git a/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestListenUrlWaiter.java b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestListenUrlWaiter.java new file mode 100644 index 0000000..15ce06f --- /dev/null +++ b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestListenUrlWaiter.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +class TestListenUrlWaiter { + @InjectSoftAssertions SoftAssertions soft; + + private static ExecutorService executor; + + @BeforeAll + static void createExecutor() { + executor = Executors.newCachedThreadPool(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @AfterAll + static void stopExecutor() throws Exception { + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + + @Test + void ioHandling() { + var clock = new AtomicLong(); + var timeout = 10L; + + var line = new AtomicReference<>(); + var waiter = new ListenUrlWaiter(clock::get, timeout, line::set); + + waiter.accept("Hello World"); + soft.assertThat(line.getAndSet(null)).isEqualTo("Hello World"); + soft.assertThat(waiter.peekListenUrls()).isNull(); + + waiter.accept(""); + soft.assertThat(line.getAndSet(null)).isEqualTo(""); + soft.assertThat(waiter.peekListenUrls()).isNull(); + + var listenLine = + "2021-05-28 12:12:25,753 INFO [io.quarkus] (main) nessie-quarkus 0.6.2-SNAPSHOT on JVM (powered by Quarkus 1.13.4.Final) started in 1.444s. Listening on: http://0.0.0.0:39423. Management interface listening on http://0.0.0.0:9000."; + waiter.accept(listenLine); + soft.assertThat(line.getAndSet(null)).isEqualTo(listenLine); + soft.assertThat(waiter.peekListenUrls()) + .containsExactly("http://0.0.0.0:39423", "http://0.0.0.0:9000"); + + // Must *not* change the already extracted listen-url + listenLine = + "2021-05-28 12:12:25,753 INFO [io.quarkus] (main) nessie-quarkus 0.6.2-SNAPSHOT on JVM (powered by Quarkus 1.13.4.Final) started in 1.444s. Listening on: http://4.2.4.2:4242"; + waiter.accept(listenLine); + soft.assertThat(line.getAndSet(null)).isEqualTo(listenLine); + soft.assertThat(waiter.peekListenUrls()) + .containsExactly("http://0.0.0.0:39423", "http://0.0.0.0:9000"); + + waiter = new ListenUrlWaiter(clock::get, timeout, line::set); + listenLine = + "2021-05-28 12:12:25,753 INFO [io.quarkus] (main) nessie-quarkus 0.6.2-SNAPSHOT on JVM (powered by Quarkus 1.13.4.Final) started in 1.444s. Listening on: https://localhost.in.some.space:12345"; + waiter.accept(listenLine); + soft.assertThat(line.getAndSet(null)).isEqualTo(listenLine); + soft.assertThat(waiter.peekListenUrls()) + .containsExactly("https://localhost.in.some.space:12345", null); + + waiter = new ListenUrlWaiter(clock::get, timeout, line::set); + listenLine = "Listening on: https://localhost.in.some.space:4242"; + waiter.accept(listenLine); + soft.assertThat(line.getAndSet(null)).isEqualTo(listenLine); + soft.assertThat(waiter.peekListenUrls()) + .containsExactly("https://localhost.in.some.space:4242", null); + } + + @RepeatedTest(20) // repeat, risk of flakiness + void timeout() { + var clock = new AtomicLong(); + var timeout = 10_000L; // long timeout, for slow CI + + var line = new AtomicReference<>(); + var waiter = new ListenUrlWaiter(clock::get, timeout, line::set); + + soft.assertThat(waiter.isTimeout()).isFalse(); + + clock.set(TimeUnit.MILLISECONDS.toNanos(timeout + 1)); + + soft.assertThat(waiter.isTimeout()).isTrue(); + + soft.assertThat(executor.submit(waiter::getListenUrls)) + .failsWithin(5, TimeUnit.SECONDS) + .withThrowableOfType(ExecutionException.class) + .withRootCauseExactlyInstanceOf(TimeoutException.class) + .withMessageEndingWith(ListenUrlWaiter.TIMEOUT_MESSAGE + ListenUrlWaiter.NOTHING_RECEIVED); + } + + @RepeatedTest(20) // repeat, risk of flakiness + void noTimeout() throws Exception { + var clock = new AtomicLong(); + var timeout = 10_000L; // long timeout, for slow CI + + // Note: the implementation uses "our clock" to check the timeout, but uses a "standard + // Future.get(time)" for the actual get. + + var line = new AtomicReference<>(); + var waiter = new ListenUrlWaiter(clock::get, timeout, line::set); + + // Clock exactly at the timeout-boundary is not a timeout + clock.set(TimeUnit.MILLISECONDS.toNanos(timeout)); + + var listenLine = + "2021-05-28 12:12:25,753 INFO [io.quarkus] (main) nessie-quarkus 0.6.2-SNAPSHOT on JVM (powered by Quarkus 1.13.4.Final) started in 1.444s. Listening on: http://4.2.4.2:4242. Management interface listening on http://4.2.4.2:2424."; + waiter.accept(listenLine); + soft.assertThat(line.getAndSet(null)).isEqualTo(listenLine); + soft.assertThat(waiter.getListenUrls()) + .containsExactly("http://4.2.4.2:4242", "http://4.2.4.2:2424"); + soft.assertThat(waiter.isTimeout()).isFalse(); + + // Clock post the timeout-boundary (so a timeout-check would trigger) + clock.set(TimeUnit.MILLISECONDS.toNanos(timeout + 1)); + soft.assertThat(waiter.getListenUrls()) + .containsExactly("http://4.2.4.2:4242", "http://4.2.4.2:2424"); + soft.assertThat(waiter.isTimeout()).isFalse(); + } +} diff --git a/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestProcessHandler.java b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestProcessHandler.java new file mode 100644 index 0000000..79fa875 --- /dev/null +++ b/apprunner/common/src/test/java/org/apache/polaris/apprunner/common/TestProcessHandler.java @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.common; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +class TestProcessHandler { + @InjectSoftAssertions SoftAssertions soft; + + private static ExecutorService executor; + + @BeforeAll + static void createExecutor() { + executor = Executors.newCachedThreadPool(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @AfterAll + static void stopExecutor() throws Exception { + executor.shutdown(); + executor.awaitTermination(10, SECONDS); + } + + @Test + void notStarted() { + var phMock = new ProcessHandlerMock(); + + soft.assertThatThrownBy(phMock.ph::stop) + .isInstanceOf(IllegalStateException.class) + .hasMessage("No process started"); + } + + @Test + void doubleStart() { + var phMock = new ProcessHandlerMock(); + + phMock.ph.started(phMock.proc); + + soft.assertThatThrownBy(() -> phMock.ph.started(phMock.proc)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Process already started"); + } + + @RepeatedTest(20) + // repeat, risk of flakiness + void processWithNoOutput() { + var phMock = new ProcessHandlerMock(); + + phMock.ph.started(phMock.proc); + + var futureListenUrl = executor.submit(phMock.ph::getListenUrls); + + while (phMock.clock.get() < TimeUnit.MILLISECONDS.toNanos(phMock.timeToUrl)) { + soft.assertThat(futureListenUrl).isNotDone(); + soft.assertAll(); + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + } + // should be exactly at (but not "past") the time to wait for the listen-url now + + // bump the clock "past" the listen-url-timeout + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + + soft.assertThat(futureListenUrl) + .failsWithin(5, SECONDS) + .withThrowableOfType(ExecutionException.class) // EE from ForkJoinPool/executor (test code) + .withRootCauseInstanceOf( + TimeoutException.class) // TE from ProcessHandler/ListenUrlWaiter.getListenUrl + .withMessageEndingWith(ListenUrlWaiter.TIMEOUT_MESSAGE + ListenUrlWaiter.NOTHING_RECEIVED); + + // Need to wait for the watchdog to finish, before we can do any further assertion + phMock.ph.watchdogExitGrace(); + + soft.assertThat(phMock.ph.isAlive()).isFalse(); + } + + @RepeatedTest(20) + // repeat, risk of flakiness + void processExitsEarly() { + var phMock = new ProcessHandlerMock(); + + phMock.ph.started(phMock.proc); + + var futureListenUrl = executor.submit(phMock.ph::getListenUrls); + + soft.assertThat(phMock.ph.isAlive()).isTrue(); + soft.assertThatThrownBy(() -> phMock.ph.getExitCode()) + .isInstanceOf(IllegalThreadStateException.class); + + phMock.exitCode.set(88); + + soft.assertThat(futureListenUrl) + .failsWithin(5, SECONDS) + .withThrowableOfType(ExecutionException.class) // EE from ForkJoinPool/executor (test code) + .withMessageEndingWith( + ListenUrlWaiter.TIMEOUT_MESSAGE + + " Process exited early, exit code is 88." + + ListenUrlWaiter.NOTHING_RECEIVED); + + // Need to wait for the watchdog to finish, before we can do any further assertion + phMock.ph.watchdogExitGrace(); + + soft.assertThat(phMock.ph.isAlive()).isFalse(); + soft.assertThat(phMock.ph.getExitCode()).isEqualTo(88); + } + + @RepeatedTest(20) + // repeat, risk of flakiness + void processLotsOfIoNoListen() throws Exception { + var phMock = new ProcessHandlerMock(); + + phMock.ph.started(phMock.proc); + + var futureListenUrl = executor.submit(phMock.ph::getListenUrls); + + var stdoutMessage = "Hello world\n"; + var message = stdoutMessage.toCharArray(); + while (phMock.clock.get() < TimeUnit.MILLISECONDS.toNanos(phMock.timeToUrl)) { + for (var c : message) { + phMock.stdout.add((byte) c); + } + soft.assertThat(futureListenUrl).isNotDone(); + soft.assertAll(); + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + } + // should be exactly at (but not "past") the time to wait for the listen-url now + + soft.assertThat(phMock.ph.remainingWaitTimeNanos()).isEqualTo(0); + soft.assertThat(phMock.ph.isAlive()).isTrue(); + + var timeoutFail = System.currentTimeMillis() + SECONDS.toMillis(10); + while (!phMock.stdout.isEmpty()) { + soft.assertThat(System.currentTimeMillis() < timeoutFail).isTrue(); + soft.assertAll(); + Thread.sleep(1L); + } + + // bump the clock "past" the listen-url-timeout + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + + soft.assertThat(futureListenUrl) + .failsWithin(5, SECONDS) + .withThrowableOfType(ExecutionException.class) // EE from ForkJoinPool/executor (test code) + .withRootCauseInstanceOf( + TimeoutException.class) // TE from ProcessHandler/ListenUrlWaiter.getListenUrl + .withMessageContaining( + ListenUrlWaiter.TIMEOUT_MESSAGE + ListenUrlWaiter.CAPTURED_LOG_FOLLOWS) + .withMessageContaining(stdoutMessage); + + // Need to wait for the watchdog to finish, before we can do any further assertion + phMock.ph.watchdogExitGrace(); + + soft.assertThat(phMock.ph.isAlive()).isFalse(); + soft.assertThat(phMock.ph.getExitCode()).isGreaterThanOrEqualTo(0); + + soft.assertThat(phMock.stdoutLines).hasSize((int) (phMock.timeToUrl / 10)); + } + + @RepeatedTest(20) + // repeat, risk of flakiness + void processLotsOfIoProperListenUrl() { + var phMock = new ProcessHandlerMock(); + + phMock.ph.started(phMock.proc); + + var futureListenUrl = executor.submit(phMock.ph::getListenUrls); + + while (phMock.clock.get() < TimeUnit.MILLISECONDS.toNanos(phMock.timeToUrl / 2)) { + for (var c : "Hello world\n".toCharArray()) { + phMock.stdout.add((byte) c); + } + soft.assertThat(futureListenUrl).isNotDone(); + soft.assertAll(); + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + } + // should be exactly at (but not "past") the time to wait for the listen-url now + + for (var c : "Quarkus startup message... Listening on: http://0.0.0.0:4242\n".toCharArray()) { + phMock.stdout.add((byte) c); + } + + // bump the clock "past" the listen-url-timeout + phMock.clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(10)); + + soft.assertThat(futureListenUrl) + .succeedsWithin(5, SECONDS) + .isEqualTo(Arrays.asList("http://0.0.0.0:4242", null)); + + soft.assertThat(phMock.ph.isAlive()).isTrue(); + soft.assertThatThrownBy(() -> phMock.ph.getExitCode()) + .isInstanceOf(IllegalThreadStateException.class); + + // The .stop() waits until the watchdog has finished its work + phMock.ph.stop(); + + soft.assertThat(phMock.ph.isAlive()).isFalse(); + soft.assertThat(phMock.ph.getExitCode()).isGreaterThanOrEqualTo(0); + + soft.assertThat(phMock.stdoutLines).hasSize((int) (phMock.timeToUrl / 10 / 2) + 1); + } + + static final class ProcessHandlerMock { + + AtomicLong clock = new AtomicLong(); + + AtomicInteger exitCode = new AtomicInteger(-1); + + // Full lines received "form the process" via stdout/stderr is collected in these lists + List stdoutLines = Collections.synchronizedList(new ArrayList<>()); + + // Data that's "written by the process" to stdout/stderr is "piped" through these queues + ArrayBlockingQueue stdout = new ArrayBlockingQueue<>(1024); + + @SuppressWarnings("InputStreamSlowMultibyteRead") + InputStream stdoutStream = + new InputStream() { + @Override + public int available() { + return stdout.size(); + } + + @Override + public int read() { + var b = stdout.poll(); + return b == null ? -1 : b.intValue(); + } + }; + + Process proc = + new Process() { + @Override + public OutputStream getOutputStream() { + throw new UnsupportedOperationException(); + } + + @Override + public InputStream getInputStream() { + return stdoutStream; + } + + @Override + public InputStream getErrorStream() { + return stdoutStream; + } + + @Override + public int waitFor() { + throw new UnsupportedOperationException(); + } + + @Override + public int exitValue() { + var ec = exitCode.get(); + if (ec < 0) { + throw new IllegalThreadStateException(); + } + return ec; + } + + @Override + public void destroy() { + exitCode.set(42); + } + + @Override + public Process destroyForcibly() { + exitCode.set(42); + return this; + } + }; + + long timeToUrl = 500; + + ProcessHandler ph = + new ProcessHandler() + .setStdoutTarget(stdoutLines::add) + .setTicker(clock::get) + .setTimeToListenUrlMillis(timeToUrl) + .setTimeStopMillis(42); + } +} diff --git a/apprunner/demo-tarball/README.md b/apprunner/demo-tarball/README.md new file mode 100644 index 0000000..5622f2a --- /dev/null +++ b/apprunner/demo-tarball/README.md @@ -0,0 +1,36 @@ + + +# Demo - Usage in the main apache/polaris repository + +## Prerequisites + +1. Checkout the main Apache Polaris repository +2. Run `./gradlew publishToMavenLocal` from the main Apache Polaris code base +3. Run `./gradlew publishToMavenLocal` from the main Apprunner code base +4. Adopt the versions in `build.gradle.kts` in this directory + +Then run the demo: + +```shell +gradle demoTest --info +``` + +`--info` is also only for the purpose of the demo to show the Polaris server startup +banned and the output produced by the `DemoTest` class. diff --git a/apprunner/demo-tarball/build.gradle.kts b/apprunner/demo-tarball/build.gradle.kts new file mode 100644 index 0000000..8bbf65e --- /dev/null +++ b/apprunner/demo-tarball/build.gradle.kts @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +plugins { + `java-library` + `jvm-test-suite` + id("org.apache.polaris.apprunner") version "0.1-SNAPSHOT" +} + +// Gradle configuration to reference the tarball +val polarisTarball by + configurations.creating { description = "Used to reference the distribution tarball" } + +dependencies { + polarisTarball("org.apache.polaris:polaris-quarkus-server:1.0.0-incubating-SNAPSHOT:@tgz") +} + +// Directory where the Polaris tarball is extracted to +val unpackedTarball = project.layout.buildDirectory.dir("polaris-tarball") + +// Extracts the Polaris tarball, truncating the path +val polarisUnpackedTarball by + tasks.registering(Sync::class) { + inputs.files(polarisTarball) + destinationDir = unpackedTarball.get().asFile + from(provider { tarTree(polarisTarball.singleFile) }) + eachFile { + // truncates the path (removes the first path element) + relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) + } + includeEmptyDirs = false + } + +testing { + suites { + val demoTest by registering(JvmTestSuite::class) + } +} + +val demoTest = + tasks.named("demoTest") { + // Dependency to have the extracted tarball + dependsOn(polarisUnpackedTarball) + + // We want to rerun the tests for the sake of the demo. DO NOT COPY THIS ONE! + outputs.upToDateWhen { false } + } + +tasks.named("check") { dependsOn(demoTest) } + +polarisQuarkusApp { + includeTask(demoTest) + // Reference the quarkus-run.jar in the tarball, apprunner plugin will then run that jar + executableJar = provider { unpackedTarball.get().file("quarkus-run.jar") } +} diff --git a/apprunner/demo-tarball/settings.gradle.kts b/apprunner/demo-tarball/settings.gradle.kts new file mode 100644 index 0000000..9408807 --- /dev/null +++ b/apprunner/demo-tarball/settings.gradle.kts @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + +pluginManagement { + repositories { + mavenCentral() // prefer Maven Central, in case Gradle's repo has issues + gradlePluginPortal() + mavenLocal() // This is only needed for the demo when publishing the Apprunner plugin from the local code base + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() // This is only needed for the demo when publishing the Apprunner plugin from the local code base + } +} diff --git a/apprunner/demo-tarball/src/demoTest/java/org/apache/polaris/apprunner/demo/DemoTest.java b/apprunner/demo-tarball/src/demoTest/java/org/apache/polaris/apprunner/demo/DemoTest.java new file mode 100644 index 0000000..74c55b0 --- /dev/null +++ b/apprunner/demo-tarball/src/demoTest/java/org/apache/polaris/apprunner/demo/DemoTest.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.demo; + +import org.junit.jupiter.api.Test; + +public class DemoTest { + @Test + public void test() { + System.err.println(System.getProperty("quarkus.http.test-port")); + System.err.println(System.getProperty("quarkus.http.test-url")); + System.err.println(System.getProperty("quarkus.management.test-port")); + System.err.println(System.getProperty("quarkus.management.test-url")); + } +} diff --git a/apprunner/dot-github b/apprunner/dot-github new file mode 120000 index 0000000..58aef9b --- /dev/null +++ b/apprunner/dot-github @@ -0,0 +1 @@ +../.github/workflows \ No newline at end of file diff --git a/apprunner/gradle-plugin/build.gradle.kts b/apprunner/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..b50626c --- /dev/null +++ b/apprunner/gradle-plugin/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +plugins { + id("polaris-apprunner-java") + `java-gradle-plugin` +} + +dependencies { + compileOnly(libs.jakarta.annotation.api) + implementation(project(":polaris-apprunner-common")) +} + +gradlePlugin { + plugins { + register("polaris-apprunner") { + id = "org.apache.polaris.apprunner" + implementationClass = "org.apache.polaris.apprunner.plugin.PolarisRunnerPlugin" + displayName = "Polaris Runner" + description = "Start and stop a Polaris server for integration testing" + tags.addAll("test", "integration", "quarkus", "polaris") + } + } + website.set("https://polaris.apache.org") + vcsUrl.set("https://github.com/apache/polaris") +} + +tasks.named("test") { + systemProperties("polaris-version" to version, "junit-version" to libs.junit.bom.get().version) +} diff --git a/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerExtension.java b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerExtension.java new file mode 100644 index 0000000..1a88094 --- /dev/null +++ b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerExtension.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import org.gradle.api.Action; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskCollection; +import org.gradle.api.tasks.TaskProvider; + +public abstract class PolarisRunnerExtension { + private final Property javaVersion; + private final Property httpListenPortProperty; + private final Property httpListenUrlProperty; + private final Property managementListenPortProperty; + private final Property managementListenUrlProperty; + private final RegularFileProperty executableJar; + private final RegularFileProperty workingDirectory; + private final Property timeToListenUrlMillis; + private final Property timeToStopMillis; + + private final Provider polarisRunnerServiceProvider; + + public PolarisRunnerExtension( + Project project, Provider polarisRunnerServiceProvider) { + this.polarisRunnerServiceProvider = polarisRunnerServiceProvider; + + javaVersion = project.getObjects().property(Integer.class).convention(21); + httpListenUrlProperty = + project.getObjects().property(String.class).convention("quarkus.http.test-url"); + httpListenPortProperty = + project.getObjects().property(String.class).convention("quarkus.http.test-port"); + managementListenUrlProperty = + project.getObjects().property(String.class).convention("quarkus.management.test-url"); + managementListenPortProperty = + project.getObjects().property(String.class).convention("quarkus.management.test-port"); + workingDirectory = + project + .getObjects() + .fileProperty() + .convention(project.getLayout().getBuildDirectory().file("polaris-quarkus-server")); + executableJar = project.getObjects().fileProperty(); + timeToListenUrlMillis = project.getObjects().property(Long.class).convention(0L); + timeToStopMillis = project.getObjects().property(Long.class).convention(0L); + } + + /** System properties for the Polaris JVM. */ + public abstract MapProperty getSystemProperties(); + + /** System properties for the Polaris JVM, not respected for Gradle build caching. */ + public abstract MapProperty getSystemPropertiesNonInput(); + + /** Environment variables for the Polaris JVM. */ + public abstract MapProperty getEnvironment(); + + /** Environment variables for the Polaris JVM, not respected for Gradle build caching. */ + public abstract MapProperty getEnvironmentNonInput(); + + /** Arguments used to start the Polaris JVM. */ + public abstract ListProperty getArguments(); + + /** Arguments used to start the Polaris JVM, not respected for Gradle build caching. */ + public abstract ListProperty getArgumentsNonInput(); + + /** JVM arguments used to start the Polaris JVM. */ + public abstract ListProperty getJvmArguments(); + + /** JVM arguments used to start the Polaris JVM, not respected for Gradle build caching. */ + public abstract ListProperty getJvmArgumentsNonInput(); + + /** The Java version to use to run Polaris, defaults to 21. */ + public Property getJavaVersion() { + return javaVersion; + } + + /** The name of the property that will receive the HTTP port number. */ + public Property getHttpListenPortProperty() { + return httpListenPortProperty; + } + + /** + * The name of the property that will receive the HTTP listen URL, in the exact form as emitted by + * Quarkus, likely containing {@code 0.0.0.0} has the host. + */ + public Property getHttpListenUrlProperty() { + return httpListenUrlProperty; + } + + /** The name of the property that will receive the management port number. */ + public Property getManagementListenPortProperty() { + return managementListenPortProperty; + } + + /** + * The name of the property that will receive the management listen URL, in the exact form as + * emitted by Quarkus, likely containing {@code 0.0.0.0} has the host. + */ + public Property getManagementListenUrlProperty() { + return managementListenUrlProperty; + } + + /** The file of the executable jar to run Polaris. */ + public RegularFileProperty getExecutableJar() { + return executableJar; + } + + /** + * Working directory used when starting Polaris, defaults to {@code build/polaris-quarkus-server} + * in the current Gradle project. + */ + public RegularFileProperty getWorkingDirectory() { + return workingDirectory; + } + + /** + * Time to wait until the plugin expects Quarkus to emit the listen URLs, defaults to 30 seconds. + */ + public Property getTimeToListenUrlMillis() { + return timeToListenUrlMillis; + } + + /** + * Time to wait until Polaris has stopped after the termination signal, defaults to 15 seconds. + */ + public Property getTimeToStopMillis() { + return timeToStopMillis; + } + + /** + * The Gradle tasks of the current Gradle project to "decorate" with a running Polaris server, + * with the HTTP and management URL and port properties. + */ + public PolarisRunnerExtension includeTasks(TaskCollection taskCollection) { + return includeTasks(taskCollection, null); + } + + /** + * The Gradle tasks of the current Gradle project to "decorate" with a running Polaris server, + * with the HTTP and management URL and port properties. + */ + public PolarisRunnerExtension includeTasks( + TaskCollection taskCollection, Action postStartAction) { + taskCollection.configureEach( + new PolarisRunnerTaskConfigurer<>(postStartAction, polarisRunnerServiceProvider)); + return this; + } + + /** + * The Gradle tasks of the current Gradle project to "decorate" with a running Polaris server, + * with the HTTP and management URL and port properties. + */ + public PolarisRunnerExtension includeTask(TaskProvider taskProvider) { + return includeTask(taskProvider, null); + } + + /** + * The Gradle tasks of the current Gradle project to "decorate" with a running Polaris server, + * with the HTTP and management URL and port properties. + */ + public PolarisRunnerExtension includeTask( + TaskProvider taskProvider, Action postStartAction) { + taskProvider.configure( + new PolarisRunnerTaskConfigurer<>(postStartAction, polarisRunnerServiceProvider)); + return this; + } +} diff --git a/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerPlugin.java b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerPlugin.java new file mode 100644 index 0000000..a6d538e --- /dev/null +++ b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerPlugin.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import java.util.concurrent.ThreadLocalRandom; +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +public class PolarisRunnerPlugin implements Plugin { + + static final String EXTENSION_NAME = "polarisQuarkusApp"; + + /** + * The name of the Gradle configuration that contains the Quarkus server application as the only + * dependency. + */ + static final String APP_CONFIG_NAME = "polarisQuarkusServer"; + + @Override + public void apply(Project project) { + project + .getConfigurations() + .register( + APP_CONFIG_NAME, + c -> + c.setTransitive(false) + .setDescription( + "References the Polaris-Quarkus server dependency, only a single dependency allowed.")); + + var runnerService = + project + .getGradle() + .getSharedServices() + .registerIfAbsent( + // Make the build-service unique per project to prevent Gradle class-cast + // exceptions when the plugin's reloaded within the same build using different + // class loaders. + "polaris-quarkus-runner-" + ThreadLocalRandom.current().nextLong(), + PolarisRunnerService.class, + spec -> {}); + + project + .getExtensions() + .create(EXTENSION_NAME, PolarisRunnerExtension.class, project, runnerService); + } +} diff --git a/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerService.java b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerService.java new file mode 100644 index 0000000..1ec12cd --- /dev/null +++ b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerService.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import java.util.IdentityHashMap; +import java.util.Map; +import org.gradle.api.Task; +import org.gradle.api.services.BuildService; +import org.gradle.api.services.BuildServiceParameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class PolarisRunnerService + implements BuildService, AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(PolarisRunnerService.class); + + private final Map processes = new IdentityHashMap<>(); + + @Override + public void close() { + synchronized (processes) { + if (!processes.isEmpty()) { + LOGGER.warn("Cleaning up {} Polaris Quarkus servers", processes.size()); + } + for (var state : processes.values()) { + state.quarkusStop(LOGGER); + } + } + } + + public void register(ProcessState processState, Task task) { + synchronized (processes) { + processes.put(task, processState); + } + } + + public void finished(Task task) { + ProcessState state; + synchronized (processes) { + state = processes.remove(task); + } + if (state != null) { + state.quarkusStop(task.getLogger()); + } + } +} diff --git a/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerTaskConfigurer.java b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerTaskConfigurer.java new file mode 100644 index 0000000..69b61b0 --- /dev/null +++ b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/PolarisRunnerTaskConfigurer.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import static org.apache.polaris.apprunner.plugin.PolarisRunnerPlugin.APP_CONFIG_NAME; + +import jakarta.annotation.Nonnull; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import org.gradle.api.Action; +import org.gradle.api.Task; +import org.gradle.api.file.RegularFile; +import org.gradle.api.plugins.ExtraPropertiesExtension; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.PathSensitivity; + +/** Configures the task for which the Polaris-Quarkus process shall be started. */ +public class PolarisRunnerTaskConfigurer implements Action { + + private final Action postStartAction; + private final Provider polarisRunnerServiceProvider; + + public PolarisRunnerTaskConfigurer( + Action postStartAction, Provider polarisRunnerServiceProvider) { + this.postStartAction = postStartAction; + this.polarisRunnerServiceProvider = polarisRunnerServiceProvider; + } + + @SuppressWarnings( + "Convert2Lambda") // Gradle complains when using lambdas (build-cache won't wonk) + @Override + public void execute(T task) { + var project = task.getProject(); + + var appConfig = project.getConfigurations().getByName(APP_CONFIG_NAME); + var extension = project.getExtensions().getByType(PolarisRunnerExtension.class); + + // Add the StartTask's properties as "inputs" to the Test task, so the Test task is + // executed, when those properties change. + var inputs = task.getInputs(); + inputs.properties(extension.getEnvironment().get()); + inputs.properties(extension.getSystemProperties().get()); + inputs.property("polaris.quarkus.arguments", extension.getArguments().get().toString()); + inputs.property("polaris.quarkus.jvmArguments", extension.getJvmArguments().get().toString()); + RegularFile execJar = extension.getExecutableJar().getOrNull(); + if (execJar != null) { + inputs.file(execJar).withPathSensitivity(PathSensitivity.RELATIVE); + } + inputs.property("polaris.quarkus.javaVersion", extension.getJavaVersion().get()); + + inputs.files(appConfig); + + var dependencies = appConfig.getDependencies(); + // Although we assert that only a single artifact is used (later), collect all dependencies + // for a nicer error message. + var dependenciesString = + dependencies.stream() + .map(d -> String.format("%s:%s:%s", d.getGroup(), d.getName(), d.getVersion())) + .collect(Collectors.joining(", ")); + var files = + !dependencies.isEmpty() + ? appConfig.getIncoming().artifactView(viewConfiguration -> {}).getFiles() + : null; + + var extra = task.getExtensions().findByType(ExtraPropertiesExtension.class); + BiConsumer httpUrlAndPortConsumer = + extra != null + ? (listenUrl, listenPort) -> { + extra.set(extension.getHttpListenUrlProperty().get(), listenUrl); + extra.set(extension.getHttpListenPortProperty().get(), listenPort); + } + : (listenUrl, listenPort) -> {}; + BiConsumer managementUrlAndPortConsumer = + extra != null + ? (listenUrl, listenPort) -> { + extra.set(extension.getManagementListenUrlProperty().get(), listenUrl); + extra.set(extension.getManagementListenPortProperty().get(), listenPort); + } + : (listenUrl, listenPort) -> {}; + + if (extra != null) { + task.notCompatibleWithConfigurationCache( + "PolarisRunner needs Gradle's extra-properties, which is incompatible with the configuration cache"); + } + + // Start the Polaris-Quarkus-App only when the Test task actually runs + + task.usesService(polarisRunnerServiceProvider); + task.doFirst( + new Action<>() { + @SuppressWarnings("unchecked") + @Override + public void execute(@Nonnull Task t) { + var processState = new ProcessState(); + polarisRunnerServiceProvider.get().register(processState, t); + + processState.quarkusStart( + t, + extension, + files, + dependenciesString, + httpUrlAndPortConsumer, + managementUrlAndPortConsumer); + if (postStartAction != null) { + postStartAction.execute((T) t); + } + } + }); + task.doLast( + new Action<>() { + @Override + public void execute(@Nonnull Task t) { + polarisRunnerServiceProvider.get().finished(t); + } + }); + } +} diff --git a/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/ProcessState.java b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/ProcessState.java new file mode 100644 index 0000000..854d5e2 --- /dev/null +++ b/apprunner/gradle-plugin/src/main/java/org/apache/polaris/apprunner/plugin/ProcessState.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import org.apache.polaris.apprunner.common.JavaVM; +import org.apache.polaris.apprunner.common.ProcessHandler; +import org.gradle.api.GradleException; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; +import org.gradle.api.file.RegularFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.JavaForkOptions; +import org.slf4j.Logger; + +public class ProcessState { + + private ProcessHandler processHandler; + + public ProcessState() { + // intentionally empty + } + + @TaskAction + public void noop() {} + + void quarkusStart( + Task task, + PolarisRunnerExtension extension, + FileCollection appConfigFiles, + String dependenciesString, + BiConsumer httpUrlAndPortConsumer, + BiConsumer managementUrlAndPortConsumer) { + + RegularFile configuredJar = extension.getExecutableJar().getOrNull(); + + File execJar; + + if (configuredJar == null) { + if (appConfigFiles != null && !appConfigFiles.isEmpty()) { + var appConfigFileSet = appConfigFiles.getFiles(); + if (appConfigFileSet.size() != 1) { + throw new GradleException( + String.format( + "Expected configuration %s to resolve to exactly one artifact, but resolves to %s (hint: do not enable transitive on the dependency)", + PolarisRunnerPlugin.APP_CONFIG_NAME, dependenciesString)); + } + execJar = appConfigFileSet.iterator().next(); + } else { + throw new GradleException( + String.format( + "Neither does the configuration %s contain exactly one dependency (preferably org.apache.polaris:polaris-quarkus-server:runner), nor is the runner jar specified in the %s extension.", + PolarisRunnerPlugin.APP_CONFIG_NAME, PolarisRunnerPlugin.EXTENSION_NAME)); + } + } else { + if (appConfigFiles != null && !appConfigFiles.isEmpty()) { + throw new GradleException( + String.format( + "Configuration %s contains a dependency and option 'executableJar' are mutually exclusive", + PolarisRunnerPlugin.APP_CONFIG_NAME)); + } + execJar = configuredJar.getAsFile(); + } + + var javaVM = JavaVM.findJavaVM(extension.getJavaVersion().get()); + if (javaVM == null) { + throw new GradleException(noJavaMessage(extension.getJavaVersion().get())); + } + + var workDir = extension.getWorkingDirectory().getAsFile().get().toPath(); + if (!Files.isDirectory(workDir)) { + try { + Files.createDirectories(workDir); + } catch (IOException e) { + throw new GradleException( + String.format("Failed to create working directory %s", workDir), e); + } + } + + var command = new ArrayList(); + command.add(javaVM.getJavaExecutable().toString()); + command.addAll(extension.getJvmArguments().get()); + command.addAll(extension.getJvmArgumentsNonInput().get()); + command.add("-Dquarkus.http.port=0"); + command.add("-Dquarkus.management.port=0"); + command.add("-Dquarkus.log.level=INFO"); + command.add("-Dquarkus.log.console.level=INFO"); + extension + .getSystemProperties() + .get() + .forEach((k, v) -> command.add(String.format("-D%s=%s", k, v))); + extension + .getSystemPropertiesNonInput() + .get() + .forEach((k, v) -> command.add(String.format("-D%s=%s", k, v))); + command.add("-jar"); + command.add(execJar.getAbsolutePath()); + command.addAll(extension.getArguments().get()); + command.addAll(extension.getArgumentsNonInput().get()); + + task.getLogger().info("Starting process: {}", command); + + var processBuilder = new ProcessBuilder().command(command); + extension.getEnvironment().get().forEach((k, v) -> processBuilder.environment().put(k, v)); + extension + .getEnvironmentNonInput() + .get() + .forEach((k, v) -> processBuilder.environment().put(k, v)); + processBuilder.directory(workDir.toFile()); + + var logger = task.getLogger(); + + try { + processHandler = new ProcessHandler(); + processHandler.setStdoutTarget(line -> logger.info("[output] {}", line)); + processHandler.start(processBuilder); + if (extension.getTimeToListenUrlMillis().get() > 0L) { + processHandler.setTimeToListenUrlMillis(extension.getTimeToListenUrlMillis().get()); + } + if (extension.getTimeToStopMillis().get() > 0L) { + processHandler.setTimeStopMillis(extension.getTimeToStopMillis().get()); + } + processHandler.getListenUrls(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GradleException(String.format("Process-start interrupted: %s", command), e); + } catch (TimeoutException e) { + throw new GradleException( + String.format("Polaris-Server/Quarkus did not emit listen URL. Process: %s", command), e); + } catch (IOException e) { + throw new GradleException(String.format("Failed to start the process %s", command), e); + } + + List listenUrls; + try { + listenUrls = processHandler.getListenUrls(); + } catch (Exception e) { + // Can safely ignore it (it invocation does not block and therefore not throw an exception). + // But make the IDE happy with this throw. + throw new RuntimeException(e); + } + + var httpListenUrl = listenUrls.getFirst(); + String httpListenPort = Integer.toString(URI.create(httpListenUrl).getPort()); + // Add the Quarkus properties as "generic properties", so any task can use them. + httpUrlAndPortConsumer.accept(httpListenUrl, httpListenPort); + + List jvmOpts; + + var managementListenUrl = listenUrls.get(1); + if (managementListenUrl != null) { + var managementListenPort = Integer.toString(URI.create(managementListenUrl).getPort()); + managementUrlAndPortConsumer.accept(managementListenUrl, managementListenPort); + + jvmOpts = + Arrays.asList( + String.format("-D%s=%s", extension.getHttpListenUrlProperty().get(), httpListenUrl), + String.format("-D%s=%s", extension.getHttpListenPortProperty().get(), httpListenPort), + String.format( + "-D%s=%s", extension.getManagementListenUrlProperty().get(), managementListenUrl), + String.format( + "-D%s=%s", + extension.getManagementListenPortProperty().get(), managementListenPort)); + } else { + jvmOpts = + Arrays.asList( + String.format("-D%s=%s", extension.getHttpListenUrlProperty().get(), httpListenUrl), + String.format( + "-D%s=%s", extension.getHttpListenPortProperty().get(), httpListenPort)); + } + + // Do not put the "dynamic" properties (quarkus.http.test-port) to the `Test` task's + // system-properties, because those are subject to the test-task's inputs, which is used + // as the build-cache key. Instead, pass the dynamic properties via a + // CommandLineArgumentProvider. + // In other words: ensure that the `Test` tasks is cacheable. + if (task instanceof JavaForkOptions test) { + test.getJvmArgumentProviders().add(() -> jvmOpts); + } + } + + void quarkusStop(Logger logger) { + if (processHandler == null) { + logger.debug("No application found."); + return; + } + + try { + processHandler.stop(); + logger.info("Quarkus application stopped."); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + processHandler = null; + } + } + + static String noJavaMessage(int version) { + return String.format( + "Could not find a Java-VM for Java version %d. " + + "Set the Java-Home for a compatible JVM using the environment variable JDK%d_HOME or " + + "JAVA%d_HOME.", + version, version, version); + } +} diff --git a/apprunner/gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.apache.polaris.apprunner b/apprunner/gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.apache.polaris.apprunner new file mode 100644 index 0000000..85a3001 --- /dev/null +++ b/apprunner/gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.apache.polaris.apprunner @@ -0,0 +1 @@ +implementation=org.apache.polaris.apprunner.plugin.PolarisRunnerPlugin diff --git a/apprunner/gradle-plugin/src/test/java/org/apache/polaris/apprunner/plugin/TestPolarisRunnerPlugin.java b/apprunner/gradle-plugin/src/test/java/org/apache/polaris/apprunner/plugin/TestPolarisRunnerPlugin.java new file mode 100644 index 0000000..44b226b --- /dev/null +++ b/apprunner/gradle-plugin/src/test/java/org/apache/polaris/apprunner/plugin/TestPolarisRunnerPlugin.java @@ -0,0 +1,326 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.BuildTask; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +/** + * Test for the {@link PolarisRunnerPlugin}, which basically simulates what the {@code build.gradle} + * in Apache Iceberg does. + */ +@ExtendWith(SoftAssertionsExtension.class) +class TestPolarisRunnerPlugin { + @InjectSoftAssertions SoftAssertions soft; + @TempDir Path testProjectDir; + + Path buildFile; + + String nessieVersionForTest; + + List prefix; + + @BeforeEach + void setup() throws Exception { + buildFile = testProjectDir.resolve("build.gradle"); + var localBuildCacheDirectory = testProjectDir.resolve(".local-cache"); + + // Copy our test class in the test's project test-source folder + var testTargetDir = testProjectDir.resolve("src/test/java/org/apache/polaris/apprunner/plugin"); + Files.createDirectories(testTargetDir); + Files.copy( + Paths.get( + "src/test/resources/org/apache/polaris/apprunner/plugin/TestSimulatingTestUsingThePlugin.java"), + testTargetDir.resolve("TestSimulatingTestUsingThePlugin.java")); + + Files.write( + testProjectDir.resolve("settings.gradle"), + Arrays.asList( + "buildCache {", + " local {", + " directory '" + localBuildCacheDirectory.toUri() + "'", + " }", + "}", + "", + "include 'sub'")); + + // Versions injected from build.gradle.kts - this is actually a Nessie version + nessieVersionForTest = System.getProperty("polaris-version-for-test", "0.101.3"); + var junitVersion = System.getProperty("junit-version"); + + soft.assertThat(junitVersion != null) + .withFailMessage( + "System property required for this test is missing, run this test via Gradle or set the system properties manually") + .isTrue(); + + prefix = + Arrays.asList( + "plugins {", + " id 'java'", + " id 'org.apache.polaris.apprunner'", + "}", + "", + "repositories {", + " mavenLocal()", + " mavenCentral()", + "}", + "", + "test {", + " useJUnitPlatform()", + "}", + "", + "dependencies {", + " testImplementation 'org.junit.jupiter:junit-jupiter-api:" + junitVersion + "'", + " testImplementation 'org.junit.jupiter:junit-jupiter-engine:" + junitVersion + "'", + " testImplementation 'org.projectnessie.nessie:nessie-client:" + + nessieVersionForTest + + "'"); + } + + /** + * Ensure that the plugin fails when there is no dependency specified for the {@code + * polarisQuarkusServer} configuration. + */ + @Test + void noAppConfigDeps() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + "}", "", "polarisQuarkusApp {", " includeTask(tasks.named(\"test\"))", "}")) + .collect(Collectors.toList())); + + var result = createGradleRunner("test").buildAndFail(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.FAILED); + soft.assertThat(Arrays.asList(result.getOutput().split("\n"))) + .contains( + "> Neither does the configuration polarisQuarkusServer contain exactly one dependency (preferably org.apache.polaris:polaris-quarkus-server:runner), nor is the runner jar specified in the polarisQuarkusApp extension."); + } + + /** + * Ensure that the plugin works with a declared dependency via the {@code polarisQuarkusServer} + * configuration. + */ + @Test + void withDependencyDeclaration() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ":runner'", + "}", + "", + "polarisQuarkusApp.includeTask(tasks.named(\"test\"))")) + .collect(Collectors.toList())); + + var result = createGradleRunner("test").build(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.SUCCESS); + } + + /** + * Ensure that the plugin fails when there is more than one dependency specified for the {@code + * polarisQuarkusServer} configuration. + */ + @Test + void tooManyAppConfigDeps() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ":runner'", + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-model:" + + nessieVersionForTest + + "'", + "}", + "", + "polarisQuarkusApp.includeTask(tasks.named(\"test\"))")) + .collect(Collectors.toList())); + + var result = createGradleRunner("test").buildAndFail(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.FAILED); + soft.assertThat(Arrays.asList(result.getOutput().split("\n"))) + .contains( + "> Expected configuration polarisQuarkusServer to resolve to exactly one artifact, " + + "but resolves to org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ", org.projectnessie.nessie:nessie-model:" + + nessieVersionForTest + + " (hint: do not enable transitive on the dependency)"); + } + + /** + * Ensure that the plugin fails when both the config-dependency and the exec-jar are specified. + */ + @Test + void configAndExecJar() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ":runner'", + "}", + "", + "polarisQuarkusApp {", + " executableJar.set(jar.archiveFile.get())", + " includeTask(tasks.named(\"test\"))", + "}")) + .collect(Collectors.toList())); + + soft.assertThat(createGradleRunner("jar").build().task(":jar")) + .extracting(BuildTask::getOutcome) + .isNotEqualTo(TaskOutcome.FAILED); + + var result = createGradleRunner("test").buildAndFail(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.FAILED); + soft.assertThat(Arrays.asList(result.getOutput().split("\n"))) + .contains( + "> Configuration polarisQuarkusServer contains a dependency and option 'executableJar' are mutually exclusive"); + } + + /** Ensure that the plugin fails when it doesn't find a matching Java. */ + @Test + void unknownJdk() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ":runner'", + "}", + "", + "polarisQuarkusApp {", + " javaVersion.set(42)", + " includeTask(tasks.named(\"test\"))", + "}")) + .collect(Collectors.toList())); + + BuildResult result = createGradleRunner("test").buildAndFail(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.FAILED); + soft.assertThat(Arrays.asList(result.getOutput().split("\n"))) + .contains("> " + ProcessState.noJavaMessage(42)); + } + + /** + * Starting the Polaris-Server via the Polaris-Quarkus-Gradle-Plugin must work fine, even if a + * different nessie-client version is being used (despite whether having conflicting versions + * makes any sense). + */ + @Test + void conflictingDependenciesNessie() throws Exception { + Files.write( + buildFile, + Stream.concat( + prefix.stream(), + Stream.of( + " implementation 'org.projectnessie.nessie:nessie-client:0.101.0'", + " polarisQuarkusServer 'org.projectnessie.nessie:nessie-quarkus:" + + nessieVersionForTest + + ":runner'", + "}", + "", + "polarisQuarkusApp {", + " includeTask(tasks.named(\"test\"))", + "}")) + .collect(Collectors.toList())); + + var result = createGradleRunner("test").build(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.SUCCESS); + + soft.assertThat(Arrays.asList(result.getOutput().split("\n"))) + .anyMatch(l -> l.contains("Listening on: http://0.0.0.0:")) + .contains("Quarkus application stopped."); + + // 2nd run must be up-to-date + + result = createGradleRunner("test").build(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.UP_TO_DATE); + + // 3rd run after a 'clean' must use the cached result + + result = createGradleRunner("clean").build(); + soft.assertThat(result.task(":clean")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.SUCCESS); + + result = createGradleRunner("test").build(); + soft.assertThat(result.task(":test")) + .isNotNull() + .extracting(BuildTask::getOutcome) + .isEqualTo(TaskOutcome.FROM_CACHE); + } + + private GradleRunner createGradleRunner(String task) { + return GradleRunner.create() + .withPluginClasspath() + .withProjectDir(testProjectDir.toFile()) + .withArguments("--no-configuration-cache", "--build-cache", "--info", "--stacktrace", task) + .withDebug(true) + .forwardOutput(); + } +} diff --git a/apprunner/gradle-plugin/src/test/resources/org/apache/polaris/apprunner/plugin/TestSimulatingTestUsingThePlugin.java b/apprunner/gradle-plugin/src/test/resources/org/apache/polaris/apprunner/plugin/TestSimulatingTestUsingThePlugin.java new file mode 100644 index 0000000..2f09315 --- /dev/null +++ b/apprunner/gradle-plugin/src/test/resources/org/apache/polaris/apprunner/plugin/TestSimulatingTestUsingThePlugin.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.plugin; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.projectnessie.client.NessieClientBuilder; +import org.projectnessie.client.api.NessieApiV2; +import org.projectnessie.model.Branch; + +/** + * This is not a test for the plugin itself, this is a test that is run BY the test for the plugin. + */ +class TestSimulatingTestUsingThePlugin { + @Test + void pingNessie() throws Exception { + var port = System.getProperty("quarkus.http.test-port"); + assertNotNull(port); + var url = System.getProperty("quarkus.http.test-url"); + assertNotNull(url); + + var uri = String.format("http://127.0.0.1:%s/api/v2", port); + + var client = NessieClientBuilder.createClientBuilderFromSystemSettings().withUri(uri).build(NessieApiV2.class); + // Just some simple REST request to verify that Polaris is started - no fancy interactions w/ Nessie + var config = client.getConfig(); + + // We have seen that HTTP/POST requests can fail with conflicting dependencies + client.createReference().sourceRefName("main").reference(Branch.of("foo-" + System.nanoTime(), config.getNoAncestorHash())).create(); + } +} diff --git a/apprunner/gradle.properties b/apprunner/gradle.properties new file mode 100644 index 0000000..97762d8 --- /dev/null +++ b/apprunner/gradle.properties @@ -0,0 +1,30 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# enable the Gradle build cache +org.gradle.caching=true +# enable Gradle parallel builds +org.gradle.parallel=true +# configure only necessary Gradle tasks +org.gradle.configureondemand=true +# explicitly disable the configuration cache +org.gradle.configuration-cache=false +#org.gradle.configuration-cache-problems=warn +# bump the Gradle daemon heap size (you can set bigger heap sizes as well) +org.gradle.jvmargs=-Xms2g -Xmx4g -XX:MaxMetaspaceSize=768m diff --git a/apprunner/gradle/baselibs.versions.toml b/apprunner/gradle/baselibs.versions.toml new file mode 100644 index 0000000..f35a940 --- /dev/null +++ b/apprunner/gradle/baselibs.versions.toml @@ -0,0 +1,26 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[libraries] +errorprone = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version = "4.1.0" } +idea-ext = { module = "gradle.plugin.org.jetbrains.gradle.plugin.idea-ext:gradle-idea-ext", version = "1.1.10" } +license-report = { module = "com.github.jk1:gradle-license-report", version = "2.9" } +nexus-publish = { module = "io.github.gradle-nexus:publish-plugin", version = "2.0.0" } +shadow = { module = "com.gradleup.shadow:shadow-gradle-plugin", version = "8.3.6" } +spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "7.0.3" } diff --git a/apprunner/gradle/gradlew-include.sh b/apprunner/gradle/gradlew-include.sh new file mode 100644 index 0000000..38251e3 --- /dev/null +++ b/apprunner/gradle/gradlew-include.sh @@ -0,0 +1,65 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Downloads the gradle-wrapper.jar if necessary and verifies its integrity. +# Included from /.gradlew + +# Extract the Gradle version from gradle-wrapper.properties. +GRADLE_DIST_VERSION="$(grep distributionUrl= "$APP_HOME/gradle/wrapper/gradle-wrapper.properties" | sed 's/^.*gradle-\([0-9.]*\)-[a-z]*.zip$/\1/')" +GRADLE_WRAPPER_SHA256="$APP_HOME/gradle/wrapper/gradle-wrapper-${GRADLE_DIST_VERSION}.jar.sha256" +GRADLE_WRAPPER_JAR="$APP_HOME/gradle/wrapper/gradle-wrapper.jar" +if [ -x "$(command -v sha256sum)" ] ; then + SHASUM="sha256sum" +else + if [ -x "$(command -v shasum)" ] ; then + SHASUM="shasum -a 256" + else + echo "Neither sha256sum nor shasum are available, install either." > /dev/stderr + exit 1 + fi +fi +if [ ! -e "${GRADLE_WRAPPER_SHA256}" ]; then + # Delete the wrapper jar, if the checksum file does not exist. + rm -f "${GRADLE_WRAPPER_JAR}" +fi +if [ -e "${GRADLE_WRAPPER_JAR}" ]; then + # Verify the wrapper jar, if it exists, delete wrapper jar and checksum file, if the checksums + # do not match. + JAR_CHECKSUM="$(${SHASUM} "${GRADLE_WRAPPER_JAR}" | cut -d\ -f1)" + EXPECTED="$(cat "${GRADLE_WRAPPER_SHA256}")" + if [ "${JAR_CHECKSUM}" != "${EXPECTED}" ]; then + rm -f "${GRADLE_WRAPPER_JAR}" "${GRADLE_WRAPPER_SHA256}" + fi +fi +if [ ! -e "${GRADLE_WRAPPER_SHA256}" ]; then + curl --location --output "${GRADLE_WRAPPER_SHA256}" https://services.gradle.org/distributions/gradle-${GRADLE_DIST_VERSION}-wrapper.jar.sha256 || exit 1 +fi +if [ ! -e "${GRADLE_WRAPPER_JAR}" ]; then + # The Gradle version extracted from the `distributionUrl` property does not contain ".0" patch + # versions. Need to append a ".0" in that case to download the wrapper jar. + GRADLE_VERSION="$(echo "$GRADLE_DIST_VERSION" | sed 's/^\([0-9]*[.][0-9]*\)$/\1.0/')" + curl --location --output "${GRADLE_WRAPPER_JAR}" https://raw.githubusercontent.com/gradle/gradle/v${GRADLE_VERSION}/gradle/wrapper/gradle-wrapper.jar || exit 1 + JAR_CHECKSUM="$(${SHASUM} "${GRADLE_WRAPPER_JAR}" | cut -d\ -f1)" + EXPECTED="$(cat "${GRADLE_WRAPPER_SHA256}")" + if [ "${JAR_CHECKSUM}" != "${EXPECTED}" ]; then + # If the (just downloaded) checksum and the downloaded wrapper jar do not match, something + # really bad is going on. + echo "Expected sha256 of the downloaded gradle-wrapper.jar does not match the downloaded sha256!" > /dev/stderr + exit 1 + fi +fi diff --git a/apprunner/gradle/libs.versions.toml b/apprunner/gradle/libs.versions.toml new file mode 100644 index 0000000..9806f89 --- /dev/null +++ b/apprunner/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[versions] +soebes-itf = "0.13.1" + +[libraries] +assertj-core = { module = "org.assertj:assertj-core", version = "3.27.2" } +errorprone = { module = "com.google.errorprone:error_prone_core", version = "2.36.0" } +maven-core = { module = "org.apache.maven:maven-core", version = "3.9.9" } +maven-plugin-annotations = { module = "org.apache.maven.plugin-tools:maven-plugin-annotations", version = "3.15.1" } +jakarta-annotation-api = { module = "jakarta.annotation:jakarta.annotation-api", version = "3.0.0" } +junit-bom = { module = "org.junit:junit-bom", version = "5.11.4" } +soebes-itf-assertj = { module = "com.soebes.itf.jupiter.extension:itf-assertj", version.ref = "soebes-itf" } +soebes-itf-jupiter-extension = { module = "com.soebes.itf.jupiter.extension:itf-jupiter-extension", version.ref = "soebes-itf" } + +[plugins] +maven-plugin = { id = "org.gradlex.maven-plugin-development", version = "1.0.1" } +rat = { id = "org.nosphere.apache.rat", version = "0.8.1" } diff --git a/apprunner/gradle/wrapper/gradle-wrapper.properties b/apprunner/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f384b70 --- /dev/null +++ b/apprunner/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,28 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +# See https://gradle.org/release-checksums/ for valid checksums +distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apprunner/gradlew b/apprunner/gradlew new file mode 100755 index 0000000..ee38727 --- /dev/null +++ b/apprunner/gradlew @@ -0,0 +1,253 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +. ${APP_HOME}/gradle/gradlew-include.sh + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apprunner/ide-name.txt b/apprunner/ide-name.txt new file mode 100644 index 0000000..0328da3 --- /dev/null +++ b/apprunner/ide-name.txt @@ -0,0 +1 @@ +Polaris Apprunner diff --git a/apprunner/maven-plugin/build.gradle.kts b/apprunner/maven-plugin/build.gradle.kts new file mode 100644 index 0000000..14a1fde --- /dev/null +++ b/apprunner/maven-plugin/build.gradle.kts @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import org.apache.tools.ant.filters.ReplaceTokens + +plugins { + id("polaris-apprunner-java") + alias(libs.plugins.maven.plugin) +} + +val deps by configurations.creating +val maven by configurations.creating + +configurations.implementation.get().extendsFrom(deps) + +dependencies { + deps(project(":polaris-apprunner-common")) + implementation(libs.maven.core) + compileOnly(libs.maven.plugin.annotations) + compileOnly(libs.jakarta.annotation.api) + testImplementation(libs.soebes.itf.jupiter.extension) + testImplementation(libs.soebes.itf.assertj) + maven( + group = "org.apache.maven", + name = "apache-maven", + version = libs.maven.core.get().version, + classifier = "bin", + ext = "tar.gz", + ) +} + +mavenPlugin { + helpMojoPackage.set("org.apache.polaris.apprunner.maven") + artifactId = project.name + groupId = project.group.toString() + dependencies = deps +} + +// The following stuff is needed by the Maven integration tests. +// +// The Maven plugin integration tests use `com.soebes.itf.jupiter.extension`, which is admittedly a +// bit "dusty", +// but it works. That extension however is built to be run inside a Maven build, but here it is +// Gradle, so we +// have to do some things manually to get that Maven-plugin-integration-test-framework working. + +// Does what it says, download and unpack a Maven distribution, needed by the IT-framework. +val getMavenDistro by + tasks.registering(Sync::class) { + from(tarTree(maven.singleFile)) { eachFile { path = path.substring(path.indexOf('/') + 1) } } + into(layout.buildDirectory.dir("maven")) + } + +// soebes-itf expects the artifacts of/for the Maven plugin to be tested in `target/itf-repo` in the +// layout of +// a local Maven repo. Sadly, Gradle offers no standard way to publish artifacts to a "custom" local +// Maven repo, +// so this task publishes the required artifacts to the user's local Maven repo and then copies the +// published +// artifacts to `target/itf-repo`. +val itfRepo by + tasks.registering(Sync::class) { + // polaris-apprunner parent pom + dependsOn(":publishToMavenLocal") + // polaris-apprunner-common pom + jar + dependsOn(":polaris-apprunner-common:publishToMavenLocal") + // polaris-apprunner-maven-plugin pom + jar + dependsOn("publishToMavenLocal") + + // Poor-man's way to convert the group-ID to a path + val groupPath = project.group.toString().replace(".", "/") + // Note: this assumes the user has his local Maven repository in $HOME/.m2/repository. This does + // NOT work for + // any other location, whether it's configured using Maven properties or a settings.xml. If such + // a support is + // required, please add support for that and open a PR. + val localMavenRepo = "${System.getProperty("user.home")}/.m2/repository" + from(localMavenRepo) + include("$groupPath/**") + into(layout.projectDirectory.dir("target/itf-repo")) + } + +// Copy the Maven projects used by the integration-tests, while replacing the necessary placeholders +// for GAV and +// dependencies used by those tests. +val syncResourcesIts by + tasks.registering(Sync::class) { + from("src/test/resources-its") + into(project.layout.projectDirectory.dir("target/test-classes")) + filter( + ReplaceTokens::class, + mapOf( + "tokens" to + mapOf( + "projectGroupId" to project.group.toString(), + "projectArtifactId" to project.name, + "projectVersion" to project.version, + "junitVersion" to libs.junit.bom.get().version, + ) + ), + ) + } + +tasks.named("test") { + dependsOn(syncResourcesIts, itfRepo, getMavenDistro) + jvmArgumentProviders.add( + CommandLineArgumentProvider { + listOf("-Dmaven.home=${getMavenDistro.get().outputs.files.singleFile}") + } + ) + environment( + mapOf("JAVA_HOME" to this.javaLauncher.get().metadata.installationPath.asFile.toString()) + ) +} diff --git a/apprunner/maven-plugin/pom.xml b/apprunner/maven-plugin/pom.xml new file mode 100644 index 0000000..774520a --- /dev/null +++ b/apprunner/maven-plugin/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + org.apache.polaris.apprunner + polaris-apprunner-maven + 0.42-SNAPSHOT + + + UTF-8 + + diff --git a/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/AbstractPolarisRunnerMojo.java b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/AbstractPolarisRunnerMojo.java new file mode 100644 index 0000000..66ea151 --- /dev/null +++ b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/AbstractPolarisRunnerMojo.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.maven; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; +import org.apache.polaris.apprunner.common.ProcessHandler; + +/* + * Base class to share configuration between mojo. + */ +abstract class AbstractPolarisRunnerMojo extends AbstractMojo { + private static final String CONTEXT_KEY = "polaris.quarkus.app"; + + /** Maven project. */ + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + /** Maven session. */ + @Parameter(defaultValue = "${session}", readonly = true, required = true) + private MavenSession session; + + /** Whether execution should be skipped. */ + @Parameter(property = "polaris.apprunner.skip", required = false, defaultValue = "false") + private boolean skip; + + /** Execution id for the app. */ + @Parameter(property = "polaris.apprunner.executionId", required = false, defaultValue = "default") + private String executionId; + + public boolean isSkipped() { + return skip; + } + + public String getExecutionId() { + return executionId; + } + + public MavenProject getProject() { + return project; + } + + public MavenSession getSession() { + return session; + } + + private String getContextKey() { + final String key = CONTEXT_KEY + '.' + getExecutionId(); + return key; + } + + protected ProcessHandler getApplication() { + final String key = getContextKey(); + return (ProcessHandler) project.getContextValue(key); + } + + protected void resetApplication() { + final String key = getContextKey(); + project.setContextValue(key, null); + } + + protected void setApplicationHandle(ProcessHandler application) { + final String key = getContextKey(); + final Object previous = project.getContextValue(key); + if (previous != null) { + getLog() + .warn( + String.format( + "Found a previous ProcessHandler for execution id %s.", getExecutionId())); + } + project.setContextValue(key, application); + } +} diff --git a/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStartMojo.java b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStartMojo.java new file mode 100644 index 0000000..dc9255c --- /dev/null +++ b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStartMojo.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.maven; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import java.util.stream.Collectors; +import javax.inject.Inject; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.toolchain.ToolchainManager; +import org.apache.polaris.apprunner.common.JavaVM; +import org.apache.polaris.apprunner.common.ProcessHandler; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactRequest; +import org.eclipse.aether.resolution.ArtifactResolutionException; +import org.eclipse.aether.resolution.ArtifactResult; + +/** Starting Quarkus application. */ +@Mojo(name = "start", requiresDependencyResolution = ResolutionScope.NONE, threadSafe = true) +public class PolarisRunnerStartMojo extends AbstractPolarisRunnerMojo { + + /** The entry point to Aether, i.e. the component doing all the work. */ + @Inject private RepositorySystem repoSystem; + + @Inject private ToolchainManager toolchainManager; + + /** The current repository/network configuration of Maven. */ + @Parameter(defaultValue = "${repositorySystemSession}", readonly = true) + private RepositorySystemSession repoSession; + + /** + * The project's remote repositories to use for the resolution of plugins and their dependencies. + */ + @Parameter(defaultValue = "${project.remotePluginRepositories}", readonly = true) + private List remoteRepos; + + /** The plugin descriptor. */ + @Parameter(defaultValue = "${plugin}", readonly = true) + @SuppressWarnings("unused") + private PluginDescriptor pluginDescriptor; + + /** + * The application artifact id. + * + *

Needs to be present as a plugin dependency, if "executableJar" is not set. + * + *

Mutually exclusive with "executableJar". + * + *

Supported format is groupId:artifactId[:type[:classifier]]:version + */ + @Parameter(property = "polaris.apprunner.appArtifactId") + private String appArtifactId; + + /** Environment variable configuration properties. */ + @Parameter private Properties systemProperties = new Properties(); + + /** + * Properties to get from Quarkus running application. + * + *

The property key is the name of the build property to set, the value is the name of the + * quarkus configuration key to get. + */ + @Parameter private Properties environment; + + @Parameter private List arguments; + + @Parameter private List jvmArguments; + + @Parameter(defaultValue = "21") + private int javaVersion; + + /** + * The path to the executable jar to run. + * + *

Mutually exclusive with "appArtifactId" + */ + @Parameter private String executableJar; + + @Parameter(defaultValue = "quarkus.http.test-port") + private String httpListenPortProperty; + + @Parameter(defaultValue = "quarkus.http.test-url") + private String httpListenUrlProperty; + + @Parameter(defaultValue = "quarkus.management.test-port") + private String managementListenPortProperty; + + @Parameter(defaultValue = "quarkus.management.test-url") + private String managementListenUrlProperty; + + @Parameter(defaultValue = "${build.directory}/polaris-quarkus") + private String workingDirectory; + + @Parameter private long timeToListenUrlMillis; + + @Parameter private long timeToStopMillis; + + static String noJavaVMMessage(int version) { + return String.format( + "Could not find a Java-VM for Java version %d. " + + "Either configure a type=jdk in Maven's toolchain with version=%d or " + + "set the Java-Home for a compatible JVM using the environment variable JDK%d_HOME or " + + "JAVA%d_HOME.", + version, version, version, version); + } + + @Override + public void execute() throws MojoExecutionException { + if (isSkipped()) { + getLog().debug("Execution is skipped"); + return; + } + + getLog().debug(String.format("Searching for Java %d ...", javaVersion)); + String javaExecutable = + toolchainManager + .getToolchains( + getSession(), + "jdk", + Collections.singletonMap("version", Integer.toString(javaVersion))) + .stream() + .map(tc -> tc.findTool("java")) + .filter(Objects::nonNull) + .findFirst() + .orElseGet( + () -> { + getLog() + .debug( + String.format( + "... using JavaVM as Maven toolkit returned no toolchain " + + "for type==jdk and version==%d", + javaVersion)); + JavaVM javaVM = JavaVM.findJavaVM(javaVersion); + return javaVM != null ? javaVM.getJavaExecutable().toString() : null; + }); + if (javaExecutable == null) { + throw new MojoExecutionException(noJavaVMMessage(javaVersion)); + } + getLog().debug(String.format("Using javaExecutable %s", javaExecutable)); + + Path workDir = Paths.get(workingDirectory); + if (!Files.isDirectory(workDir)) { + try { + Files.createDirectories(workDir); + } catch (IOException e) { + throw new MojoExecutionException( + String.format("Failed to create working directory %s", workingDirectory), e); + } + } + + String execJar = executableJar; + if (execJar == null && appArtifactId == null) { + throw new MojoExecutionException( + "Either appArtifactId or executableJar config option must be specified, prefer appArtifactId"); + } + if (execJar == null) { + Artifact artifact = new DefaultArtifact(appArtifactId); + ArtifactRequest artifactRequest = new ArtifactRequest(artifact, remoteRepos, null); + try { + ArtifactResult result = repoSystem.resolveArtifact(repoSession, artifactRequest); + execJar = result.getArtifact().getFile().toString(); + } catch (ArtifactResolutionException e) { + throw new MojoExecutionException( + String.format("Failed to resolve artifact %s", appArtifactId), e); + } + } else if (appArtifactId != null) { + throw new MojoExecutionException( + "The options appArtifactId and executableJar are mutually exclusive"); + } + + List command = new ArrayList<>(); + command.add(javaExecutable); + if (jvmArguments != null) { + command.addAll(jvmArguments); + } + if (systemProperties != null) { + systemProperties.forEach( + (k, v) -> command.add(String.format("-D%s=%s", k.toString(), v.toString()))); + } + command.add("-Dquarkus.http.port=0"); + command.add("-Dquarkus.management.port=0"); + command.add("-Dquarkus.log.level=INFO"); + command.add("-Dquarkus.log.console.level=INFO"); + command.add("-jar"); + command.add(execJar); + if (arguments != null) { + command.addAll(arguments); + } + + getLog() + .info( + String.format( + "Starting process: %s, additional env: %s", + String.join(" ", command), + environment != null + ? environment.entrySet().stream() + .map(e -> String.format("%s=%s", e.getKey(), e.getValue())) + .collect(Collectors.joining(", ")) + : "")); + + ProcessBuilder processBuilder = new ProcessBuilder().command(command); + if (environment != null) { + environment.forEach((k, v) -> processBuilder.environment().put(k.toString(), v.toString())); + } + processBuilder.directory(workDir.toFile()); + + try { + ProcessHandler processHandler = new ProcessHandler(); + if (timeToListenUrlMillis > 0L) { + processHandler.setTimeToListenUrlMillis(timeToListenUrlMillis); + } + if (timeToStopMillis > 0L) { + processHandler.setTimeStopMillis(timeToStopMillis); + } + processHandler.setStdoutTarget(line -> getLog().info(String.format("[output] %s", line))); + processHandler.start(processBuilder); + + setApplicationHandle(processHandler); + + List listenUrls = processHandler.getListenUrls(); + + Properties projectProperties = getProject().getProperties(); + projectProperties.setProperty(httpListenUrlProperty, listenUrls.get(0)); + projectProperties.setProperty( + httpListenPortProperty, Integer.toString(URI.create(listenUrls.get(0)).getPort())); + if (listenUrls.get(1) != null) { + projectProperties.setProperty(managementListenUrlProperty, listenUrls.get(1)); + projectProperties.setProperty( + managementListenPortProperty, + Integer.toString(URI.create(listenUrls.get(1)).getPort())); + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException(String.format("Process-start interrupted: %s", command), e); + } catch (Exception e) { + throw new MojoExecutionException(String.format("Failed to start the process %s", command), e); + } + } +} diff --git a/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStopMojo.java b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStopMojo.java new file mode 100644 index 0000000..a90d4c3 --- /dev/null +++ b/apprunner/maven-plugin/src/main/java/org/apache/polaris/apprunner/maven/PolarisRunnerStopMojo.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.maven; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.polaris.apprunner.common.ProcessHandler; + +/** Stop Quarkus application. */ +@Mojo(name = "stop", requiresDependencyResolution = ResolutionScope.NONE, threadSafe = true) +public class PolarisRunnerStopMojo extends AbstractPolarisRunnerMojo { + /** Mojo execution. */ + @Override + public void execute() throws MojoExecutionException { + if (isSkipped()) { + getLog().info("Stopping Quarkus application."); + return; + } + + ProcessHandler application = getApplication(); + if (application == null) { + getLog().warn(String.format("No application found for execution id '%s'.", getExecutionId())); + return; + } + + try { + application.stop(); + getLog().info("Quarkus application stopped."); + } catch (Exception e) { + throw new MojoExecutionException("Error while stopping Quarkus application", e); + } finally { + resetApplication(); + } + } +} diff --git a/apprunner/maven-plugin/src/test/java/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin.java b/apprunner/maven-plugin/src/test/java/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin.java new file mode 100644 index 0000000..ad4a805 --- /dev/null +++ b/apprunner/maven-plugin/src/test/java/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.maven; + +import static com.soebes.itf.extension.assertj.MavenITAssertions.assertThat; + +import com.soebes.itf.jupiter.extension.MavenCLIOptions; +import com.soebes.itf.jupiter.extension.MavenGoal; +import com.soebes.itf.jupiter.extension.MavenJupiterExtension; +import com.soebes.itf.jupiter.extension.MavenOption; +import com.soebes.itf.jupiter.extension.MavenRepository; +import com.soebes.itf.jupiter.extension.MavenTest; +import com.soebes.itf.jupiter.maven.MavenExecutionResult; + +@MavenJupiterExtension +@MavenRepository +class ITPolarisMavenPlugin { + + @MavenTest + @MavenGoal("verify") + @MavenOption(MavenCLIOptions.ERRORS) + void executionJarAndApplicationIdMissing(MavenExecutionResult result) { + assertThat(result) + .isFailure() + .out() + .error() + .anyMatch( + s -> + s.contains( + "Either appArtifactId or executableJar config option must be specified, prefer appArtifactId")); + } + + @MavenTest + @MavenGoal("verify") + @MavenOption(MavenCLIOptions.ERRORS) + void executionJarAndApplicationIdSpecified(MavenExecutionResult result) { + assertThat(result) + .isFailure() + .out() + .error() + .anyMatch( + s -> s.contains("The options appArtifactId and executableJar are mutually exclusive")); + } + + @MavenTest + @MavenGoal("verify") + @MavenOption(MavenCLIOptions.ERRORS) + void applicationIdSpecified(MavenExecutionResult result) { + assertThat(result) + .isSuccessful() + .out() + .info() + .anyMatch( + s -> + s.matches( + "Starting process: .*-jar .*/nessie-quarkus-.*-runner.jar, additional env: .*HELLO=world.*")) + .anyMatch(s -> s.matches("Starting process: .*java.* -Dfoo=bar .*")) + .anyMatch(s -> s.matches("Starting process: .*java.* -Dquarkus.http.port=0 .*")) + .anyMatch(s -> s.matches("Quarkus application stopped.")); + } + + @MavenTest + @MavenGoal("verify") + @MavenOption(MavenCLIOptions.ERRORS) + @MavenOption(value = MavenCLIOptions.TOOLCHAINS, parameter = "non-java-toolchains.xml") + void unknownJdk(MavenExecutionResult result) { + assertThat(result) + .isFailure() + .out() + .error() + .anyMatch(s -> s.contains(PolarisRunnerStartMojo.noJavaVMMessage(42))); + } +} diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/pom.xml b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/pom.xml new file mode 100644 index 0000000..a1a5e82 --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/pom.xml @@ -0,0 +1,108 @@ + + + + 4.0.0 + + org.apache.polaris.maven.it-test + polaris-apprunner-maven-it-applicationIdSpecified + 0.42-SNAPSHOT + + + 8 + 0.49.0 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter-api + @junitVersion@ + test + + + org.junit.jupiter + junit-jupiter-engine + @junitVersion@ + test + + + org.projectnessie + nessie-client + ${nessie.version-for-test} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.3.1 + + + @projectGroupId@ + @projectArtifactId@ + @projectVersion@ + + org.projectnessie:nessie-quarkus:jar:runner:${nessie.version-for-test} + + bar + + + world + + + + + start + pre-integration-test + + start + + + + stop + post-integration-test + + stop + + + + + + + diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/src/test/java/org/apache/polaris/appruner/maven/mavenit/ITSimulatingTestUsingThePlugin.java b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/src/test/java/org/apache/polaris/appruner/maven/mavenit/ITSimulatingTestUsingThePlugin.java new file mode 100644 index 0000000..f54afc2 --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/applicationIdSpecified/src/test/java/org/apache/polaris/appruner/maven/mavenit/ITSimulatingTestUsingThePlugin.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.apprunner.maven.mavenit; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.projectnessie.client.api.NessieApiV1; +import org.projectnessie.client.http.HttpClientBuilder; +import org.projectnessie.model.Branch; + +/** + * This is not a test for the plugin itself, this is a test that is run BY the test for the plugin. + */ +class ITSimulatingTestUsingThePlugin { + @Test + void pingNessie() throws Exception { + String port = System.getProperty("quarkus.http.test-port"); + assertNotNull(port, "quarkus.http.test-port"); + String url = System.getProperty("quarkus.http.test-url"); + assertNotNull(url, "quarkus.http.test-url"); + + String uri = String.format("http://127.0.0.1:%s/api/v1", port); + + NessieApiV1 client = HttpClientBuilder.builder().withUri(uri).build(NessieApiV1.class); + // Just some simple REST request to verify that Nessie is started - no fancy interactions w/ Nessie + client.getConfig(); + + // We have seen that HTTP/POST requests can fail with conflicting dependencies + client.createReference().sourceRefName("main").reference(Branch.of("foo-" + System.nanoTime(), null)).create(); + } +} diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdMissing/pom.xml b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdMissing/pom.xml new file mode 100644 index 0000000..8b31ce1 --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdMissing/pom.xml @@ -0,0 +1,76 @@ + + + + 4.0.0 + + org.apache.polaris.maven.it-test + polaris-apprunner-maven-it-executionJarAndApplicationIdMissing + 0.42-SNAPSHOT + + + UTF-8 + + + + + org.junit.jupiter + junit-jupiter-api + @junitVersion@ + test + + + org.junit.jupiter + junit-jupiter-engine + @junitVersion@ + test + + + + + + + @projectGroupId@ + @projectArtifactId@ + @projectVersion@ + + + + + + start + pre-integration-test + + start + + + + stop + post-integration-test + + stop + + + + + + + diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdSpecified/pom.xml b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdSpecified/pom.xml new file mode 100644 index 0000000..8f75356 --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/executionJarAndApplicationIdSpecified/pom.xml @@ -0,0 +1,77 @@ + + + + 4.0.0 + + org.apache.polaris.maven.it-test + polaris-apprunner-maven-it-executionJarAndApplicationIdSpecified + 0.42-SNAPSHOT + + + UTF-8 + + + + + org.junit.jupiter + junit-jupiter-api + @junitVersion@ + test + + + org.junit.jupiter + junit-jupiter-engine + @junitVersion@ + test + + + + + + + @projectGroupId@ + @projectArtifactId@ + @projectVersion@ + + org.projectnessie:nessie-quarkus:jar:runner:0.101.3 + foo/bar/test.jar + + + + start + pre-integration-test + + start + + + + stop + post-integration-test + + stop + + + + + + + diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/non-java-toolchains.xml b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/non-java-toolchains.xml new file mode 100644 index 0000000..3e37919 --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/non-java-toolchains.xml @@ -0,0 +1,33 @@ + + + + + + jdk + + 42 + test + + + /tmp + + + diff --git a/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/pom.xml b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/pom.xml new file mode 100644 index 0000000..d75b21f --- /dev/null +++ b/apprunner/maven-plugin/src/test/resources-its/org/apache/polaris/apprunner/maven/ITPolarisMavenPlugin/unknownJdk/pom.xml @@ -0,0 +1,77 @@ + + + + 4.0.0 + + org.apache.polaris.maven.it-test + polaris-apprunner-maven-it-unknownJdk + 0.42-SNAPSHOT + + + UTF-8 + + + + + org.junit.jupiter + junit-jupiter-api + @junitVersion@ + test + + + org.junit.jupiter + junit-jupiter-engine + @junitVersion@ + test + + + + + + + @projectGroupId@ + @projectArtifactId@ + @projectVersion@ + + org.projectnessie:nessie-quarkus:jar:runner:0.21.2 + 42 + + + + start + pre-integration-test + + start + + + + stop + post-integration-test + + stop + + + + + + + diff --git a/apprunner/settings.gradle.kts b/apprunner/settings.gradle.kts new file mode 100644 index 0000000..c56d4f9 --- /dev/null +++ b/apprunner/settings.gradle.kts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +includeBuild("apprunner-build-logic") { name = "polaris-apprunner-build-logic" } + +if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { + throw GradleException( + """ + + Build aborted... + + The Apache Polaris build requires Java 21. + Detected Java version: ${JavaVersion.current()} + + """ + ) +} + +rootProject.name = "polaris-apprunner" + +val baseVersion = file("version.txt").readText().trim() + +fun addProject(name: String) { + var fullName = "polaris-apprunner-$name" + include(fullName) + val prj = project(":$fullName") + prj.projectDir = file(name) +} + +listOf("common", "gradle-plugin", "maven-plugin").forEach { addProject(it) } + +pluginManagement { + repositories { + mavenCentral() // prefer Maven Central, in case Gradle's repo has issues + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +gradle.beforeProject { + version = baseVersion + group = "org.apache.polaris.tools.apprunner" +} diff --git a/apprunner/version.txt b/apprunner/version.txt new file mode 100644 index 0000000..b000224 --- /dev/null +++ b/apprunner/version.txt @@ -0,0 +1 @@ +0.1-SNAPSHOT