Initial commit

The bootstrap of this project is done by leveraging
and adapting the infrastructure created in
https://github.com/spring-projects/spring-aot-smoke-tests.
This commit is contained in:
Sébastien Deleuze
2023-06-27 12:48:52 +02:00
commit 82d82b0980
53 changed files with 2859 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.settings/
.project
.classpath
build/
bin/
.gradle/
.idea/
out/
ci/pipeline.yml

16
CONTRIBUTING.adoc Normal file
View File

@@ -0,0 +1,16 @@
= Contributing
Before submitting a pull request, please make sure that:
1. You've rebased your changes on top of the current `main` branch
2. If you created a new top level directory: You've included the new directory in `settings.gradle`
3. You've run `./gradlew updateInfrastructure`
4. You've run `./gradlew :<name of the group>:<name of the smoke test>:build`
== Docker Compose tips
If your smoke test uses Docker Compose here are few tips:
* Wrap your port values in single quotes (`'8080'`)
* Do not specify hardcoded port mappings in `docker-compose.yml` - use `'8080'` rather than `'8080':'8080'`
* Configure your sample app to use the dynamic host (`YOUR_SVC_HOST`) and port (`YOUR_SVC_PORT`)

202
LICENSE.txt Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://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
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.

97
README.adoc Normal file
View File

@@ -0,0 +1,97 @@
= Spring Checkpoint Restore Smoke Tests
A suite of tests for applications using checkpoint/restore as implemented by https://github.com/CRaC/docs[Project CRaC] and as documented in https://docs.spring.io/spring-framework/reference/6.1/integration/checkpoint-restore.html[Spring Framework reference documentation].
There are two types of tests: unit tests and application tests.
Unit tests can be run using the `test` task.
The `appTest` task tests the application running on the JVM.
== Contributing
Please read and follow the link:CONTRIBUTING.adoc[contributing guide].
== How to
=== Run all of a project's smoke tests
[source,]
----
./gradlew :<name of the group>:<name of the smoke test>:build
----
for example
[source,]
----
./gradlew :boot:actuator-webmvc:build
----
=== Run a specific type of tests for a project
[source,]
----
./gradlew :<name of the group>:<name of the smoke test>:<test task name>
----
Valid test task names are:
1. `appTest` tests the application running on the JVM
2. `test` executes the unit tests on the JVM
for example
[source,]
----
./gradlew :boot:actuator-webmvc:appTest
----
=== Add a new smoke test
1. Create a new directory for your smoke test in the appropriate group
2. Include the directory in `settings.gradle` (new groups only)
3. Run `./gradlew updateInfrastructure` to add the smoke test to the status page and CI pipeline
=== Test against local changes
==== Your project uses Gradle
[source,]
----
./gradlew :<name of the group>:<name of the smoke test>:build --include-build /path/to/your/project
----
Gradle https://docs.gradle.org/current/userguide/composite_builds.html#command_line_composite[will then substitute the dependency] with your provided version.
_Hint: You can use `--include-build` multiple times._
==== Your project uses Maven or --include-build does not work
First, install the snapshots into your local Maven cache.
You can now consume those snapshots using `-PfromMavenLocal` which takes a comma-separated list of group IDs:
[source,]
----
./gradlew :rest-template:build -PfromMavenLocal=org.springframework,org.springframework.data
----
The preceding example will run the `rest-template` smoke test, resolving Spring Framework and Spring Data modules from your local Maven cache.
=== Override a dependency version
As the test doesn't use the Spring Dependency Management Plugin, you can't use the `ext['...'] = '...'` method.
Instead, use https://docs.gradle.org/current/userguide/dependency_constraints.html[Gradle dependency constraints].
Say, for example, you want to update the version of Spring Session JDBC to `3.0.0-SNAPSHOT`:
[source,]
----
dependencies {
// ...
constraints {
implementation('org.springframework.session:spring-session-jdbc:3.0.0-SNAPSHOT')
}
}
----
This works for direct and transitive dependencies.

18
STATUS.adoc Normal file
View File

@@ -0,0 +1,18 @@
= Smoke Tests Status
:toc:
:toc-title: Projects
== Framework
[%header,cols="3"]
|===
h|Smoke Test
h|appTest
h|test
|webmvc-tomcat
|image:https://ci.spring.io/api/v1/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/webmvc-tomcat-app-test/badge[link=https://ci.spring.io/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/webmvc-tomcat-app-test]
|image:https://ci.spring.io/api/v1/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/webmvc-tomcat-test/badge[link=https://ci.spring.io/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/webmvc-tomcat-test]
|===

20
build.gradle Normal file
View File

@@ -0,0 +1,20 @@
buildscript {
dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion"
}
configurations.classpath {
resolutionStrategy.useGlobalDependencySubstitutionRules = false
}
}
plugins {
id "io.spring.javaformat" version "$javaFormatVersion" apply false
id "org.jetbrains.kotlin.jvm" version "$kotlinVersion" apply false
id "org.springframework.cr.smoke-test-aggregator"
}
subprojects {
plugins.withType(JavaPlugin) {
plugins.apply("io.spring.javaformat")
}
}

6
ci/smoke-tests.yml Normal file
View File

@@ -0,0 +1,6 @@
groups:
- name: framework
smoke_tests:
- name: webmvc-tomcat
app_test: true
test: true

View File

@@ -0,0 +1,18 @@
plugins {
id "java-library"
id "io.spring.javaformat"
id "org.springframework.boot" apply false
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
}
dependencies {
api(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))
runtimeOnly("io.projectreactor.netty:reactor-netty-http")
api("org.springframework:spring-webflux")
api("org.springframework.boot:spring-boot-starter-test")
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Output from an application.
*
* @author Andy Wilkinson
*/
public class Output {
private final Path path;
private Output(Path path) {
this.path = path;
}
public List<String> lines() {
try {
return Files.readAllLines(this.path);
}
catch (IOException ex) {
throw new RuntimeException();
}
}
public static Output current() {
String property = System.getProperty("org.springframework.cr.smoketest.standard-output");
if (property == null) {
throw new IllegalStateException(
"Standard output is not available as org.springframework.cr.smoketest.standard-output "
+ "system property has not been set");
}
return new Output(new File(property).toPath());
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.assertj;
import org.assertj.core.api.AssertProvider;
import org.springframework.cr.smoketest.support.Output;
/**
* An {@link AssertProvider} for {@link OutputAssert}.
*
* @author Andy Wilkinson
*/
public class AssertableOutput implements AssertProvider<OutputAssert> {
private final Output output = Output.current();
@Override
public OutputAssert assertThat() {
return new OutputAssert(this.output);
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.assertj;
import java.util.List;
import java.util.Optional;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.error.BasicErrorMessageFactory;
import org.springframework.cr.smoketest.support.Output;
/**
* An {@link AbstractAssert} for {@link Output}.
*
* @author Andy Wilkinson
*/
public class OutputAssert extends AbstractAssert<OutputAssert, Output> {
OutputAssert(Output actual) {
super(actual, OutputAssert.class);
}
/**
* Asserts that the output has a single line matching the given contents.
* @param contents contents to match
* @return {@code this} for fluent API
*/
public OutputAssert hasSingleLineContaining(String contents) {
List<String> lines = this.actual.lines();
List<String> matchingLines = lines.stream().filter((line) -> line.contains(contents)).toList();
if (matchingLines.size() != 1) {
throwAssertionError(
new BasicErrorMessageFactory("%nExpected %s to have a single line that contains '%s' but found %s",
lines, contents, matchingLines.size()));
}
return this;
}
/**
* Asserts that the output has a line matching the given contents.
* @param contents contents to match
* @return {@code this} for fluent API
*/
public OutputAssert hasLineContaining(String contents) {
List<String> lines = this.actual.lines();
Optional<String> matchingLines = lines.stream().filter((line) -> line.contains(contents)).findAny();
if (matchingLines.isEmpty()) {
throwAssertionError(new BasicErrorMessageFactory(
"%nExpected %s to have a line that contains '%s' but found none", lines, contents));
}
return this;
}
/**
* Asserts that the output has a line matching the given contents.
* @param regex contents to match
* @return {@code this} for fluent API
*/
public OutputAssert hasLineMatching(String regex) {
List<String> lines = this.actual.lines();
Optional<String> matchingLines = lines.stream().filter((line) -> line.matches(regex)).findAny();
if (matchingLines.isEmpty()) {
throwAssertionError(new BasicErrorMessageFactory(
"%nExpected %s to have a line that matches '%s' but found none", lines, regex));
}
return this;
}
/**
* Asserts that the output doesn't have a line matching the given contents.
* @param contents contents to match
* @return {@code this} for fluent API
*/
public OutputAssert hasNoLinesContaining(String contents) {
List<String> lines = this.actual.lines();
boolean noMatch = lines.stream().noneMatch((line) -> line.contains(contents));
if (!noMatch) {
throwAssertionError(new BasicErrorMessageFactory(
"%nExpected %s to have no lines that contain '%s' but found some", lines, contents));
}
return this;
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.net.URI;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.Extensions;
import org.springframework.cr.smoketest.support.assertj.AssertableOutput;
import org.springframework.test.web.reactive.server.WebTestClient;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* JUnit Jupiter {@link ExtendWith extension} for an application test where the
* application runs on the JVM. Allows the following types to be injected as parameters:
*
* <ul>
* <li>{@link AssertableOutput} - application's redirected output</li>
* <li>{@link WebTestClient} - preconfigured with a base URL for the application's
* port</li>
* <li>{@link DockerComposeHost} annotated <code>String</code> - host name of the
* docker-compose service</li>
* <li>{@link DockerComposePort} annotated <code>int</code> - port of the docker-compose
* service</li>
* <li>{@link ApplicationUrl} annotated {@link URI} - URI to the application</li>
*
* @author Andy Wilkinson
* @author Moritz Halbritter
*/
@Retention(RUNTIME)
@Target(TYPE)
@Extensions({ @ExtendWith(AssertableOutputParameterResolver.class), @ExtendWith(WebTestClientParameterResolver.class),
@ExtendWith(DockerComposeHostParameterResolver.class), @ExtendWith(DockerComposePortParameterResolver.class),
@ExtendWith(ApplicationUrlParameterResolver.class), @ExtendWith(AwaitApplication.class) })
public @interface ApplicationTest {
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.springframework.cr.smoketest.support.Output;
/**
* Provides access to data about the application which is being tested.
*
* @author Moritz Halbritter
*/
final class ApplicationUnderTest {
private ApplicationUnderTest() {
}
private int port = -1;
/**
* Gets the port of the application.
* @return the application port
*/
int getPort() {
if (this.port != -1) {
return this.port;
}
List<Pattern> portPatterns = List.of(Pattern.compile("Tomcat started on port\\(s\\): ([0-9]+)"),
Pattern.compile("Netty started on port ([0-9]+)"),
Pattern.compile("Jetty started on port\\(s\\) ([0-9]+)"),
Pattern.compile("Undertow started on port\\(s\\) ([0-9]+)"));
List<String> lines = Output.current().lines();
for (String line : lines) {
for (Pattern portPattern : portPatterns) {
Matcher matcher = portPattern.matcher(line);
if (matcher.find()) {
this.port = Integer.parseInt(matcher.group(1));
return this.port;
}
}
}
StringBuilder message = new StringBuilder("Port log message was not found in output:");
message.append("\n\n");
if (lines.isEmpty()) {
message.append("<< none >>");
}
else {
for (String line : lines) {
message.append(line).append("\n");
}
}
message.append("\n");
throw new IllegalStateException(message.toString());
}
/**
* Returns the current {@link ApplicationUnderTest} for the given
* {@link ExtensionContext}. Never returns null.
* @param extensionContext JUnit extension context
* @return current {@link ApplicationUnderTest}
*/
static ApplicationUnderTest get(ExtensionContext extensionContext) {
Store store = extensionContext.getStore(Namespace.create(ApplicationUnderTest.class));
return (ApplicationUnderTest) store.getOrComputeIfAbsent(ApplicationUnderTest.class,
(c) -> new ApplicationUnderTest());
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.net.URI;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* {@link URI} parameters of JUnit test methods annotated with this annotation will be
* resolved to the URI for the running Spring Boot application.
*
* @author Moritz Halbritter
*/
@Retention(RUNTIME)
@Target({ FIELD, PARAMETER })
public @interface ApplicationUrl {
/**
* Scheme of the produced {@link URI}.
* @return the scheme
*/
Scheme scheme() default Scheme.HTTP;
enum Scheme {
WEBSOCKET("ws"), SECURE_WEBSOCKET("wss"), HTTP("http"), HTTPS("https");
private final String scheme;
Scheme(String scheme) {
this.scheme = scheme;
}
String getScheme() {
return scheme;
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.net.URI;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
/**
* {@link ParameterResolver} for {@link ApplicationUrl} annotated {@link URI}s.
*
* @author Moritz Halbritter
*/
public class ApplicationUrlParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return URI.class.equals(parameterContext.getParameter().getType())
&& parameterContext.isAnnotated(ApplicationUrl.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
ApplicationUrl annotation = parameterContext.findAnnotation(ApplicationUrl.class).get();
return URI.create(annotation.scheme().getScheme() + "://localhost:"
+ ApplicationUnderTest.get(extensionContext).getPort());
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.springframework.cr.smoketest.support.assertj.AssertableOutput;
/**
* {@link ParameterResolver} for {@link AssertableOutput}.
*
* @author Andy Wilkinson
*/
class AssertableOutputParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return AssertableOutput.class.equals(parameterContext.getParameter().getType());
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return new AssertableOutput();
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.regex.Pattern;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.cr.smoketest.support.Output;
/**
* {@link BeforeAllCallback} that waits for the application to have started.
*
* @author Andy Wilkinson
*/
class AwaitApplication implements BeforeAllCallback {
private static final Duration START_TIMEOUT = Duration.ofSeconds(30);
private Pattern APPLICATION_STARTED = Pattern
.compile("Started [A-Za-z0-9]+ in [0-9\\.]+ seconds \\(process running for [0-9\\.]+\\)");
@Override
public void beforeAll(ExtensionContext context) throws Exception {
Output output = Output.current();
long end = Instant.now().plus(START_TIMEOUT).toEpochMilli();
List<String> lines = null;
while (System.currentTimeMillis() < end) {
lines = output.lines();
for (String line : lines) {
if (this.APPLICATION_STARTED.matcher(line).find()) {
return;
}
}
}
StringBuilder message = new StringBuilder(
"Started log message was not detected within " + START_TIMEOUT + " in output:");
message.append("\n\n");
if (lines == null || lines.isEmpty()) {
message.append("<< none >>");
}
else {
for (String line : lines) {
message.append(line + "\n");
}
}
message.append("\n");
System.err.println(message.toString());
throw new IllegalStateException(message.toString());
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* <code>String</code> parameters of JUnit test methods annotated with this annotation
* will be resolved to the docker-compose host name for a given service.
*
* @author Moritz Halbritter
*/
@Retention(RUNTIME)
@Target({ FIELD, PARAMETER })
public @interface DockerComposeHost {
/**
* Name of the docker-compose service.
* @return name of the docker-compose service.
*/
String value();
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.util.Locale;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
/**
* {@link ParameterResolver} for {@link DockerComposeHost} annotated <code>String</code>s.
*
* @author Moritz Halbritter
*/
class DockerComposeHostParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return String.class.equals(parameterContext.getParameter().getType())
&& parameterContext.isAnnotated(DockerComposeHost.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
DockerComposeHost annotation = parameterContext.findAnnotation(DockerComposeHost.class).get();
String serviceName = annotation.value();
String envVariable = serviceName.toUpperCase(Locale.ENGLISH) + "_HOST";
String value = System.getenv(envVariable);
if (value == null) {
throw new ParameterResolutionException(
"Can't get host name for docker-compose service '%s', the environment variable '%s' is missing"
.formatted(serviceName, envVariable));
}
return value;
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* <code>int</code> parameters of JUnit test methods annotated with this annotation will
* be resolved to the docker-compose port for a given service.
*
* @author Moritz Halbritter
*/
@Retention(RUNTIME)
@Target({ FIELD, PARAMETER })
public @interface DockerComposePort {
/**
* Name of the docker-compose service.
* @return name of the docker-compose service.
*/
String service();
/**
* Port number of the docker-compose service.
* @return port number of the docker-compose service.
*/
int port();
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import java.util.Locale;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
/**
* {@link ParameterResolver} for {@link DockerComposePort} annotated <code>int</code>s.
*
* @author Moritz Halbritter
*/
class DockerComposePortParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return int.class.equals(parameterContext.getParameter().getType())
&& parameterContext.isAnnotated(DockerComposePort.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
DockerComposePort annotation = parameterContext.findAnnotation(DockerComposePort.class).get();
String serviceName = annotation.service();
int port = annotation.port();
String envVariable = serviceName.toUpperCase(Locale.ENGLISH) + "_PORT_" + port;
String value = System.getenv(envVariable);
if (value == null) {
throw new ParameterResolutionException(
"Can't get mapped port for docker-compose service '%s' port %d, the environment variable '%s' is missing"
.formatted(serviceName, port, envVariable));
}
return Integer.parseInt(value);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.smoketest.support.junit;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient.Builder;
/**
* A {@link ParameterResolver} for {@link WebTestClient}. The {@code WebTestClient} is
* built with a {@link Builder#baseUrl(String) base URL} for the application under test.
*
* @author Andy Wilkinson
*/
class WebTestClientParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return WebTestClient.class.equals(parameterContext.getParameter().getType());
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
Store store = extensionContext.getRoot().getStore(Namespace.create(WebTestClient.class));
return store.getOrComputeIfAbsent(WebTestClient.class, (c) -> createWebTestClient(extensionContext));
}
private WebTestClient createWebTestClient(ExtensionContext extensionContext) {
return WebTestClient.bindToServer()
.baseUrl("http://localhost:" + ApplicationUnderTest.get(extensionContext).getPort())
.build();
}
}

View File

@@ -0,0 +1 @@
Tests if WebMVC with Tomcat is working.

View File

@@ -0,0 +1,18 @@
plugins {
id "java"
id "org.springframework.boot"
id "org.springframework.cr.smoke-test"
}
dependencies {
implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
appTestImplementation(project(":cr-smoke-test-support"))
}
crSmokeTest {
webApplication = true
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2022 the original author or 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.
*/
package com.example.webmvc;
import org.junit.jupiter.api.Test;
import org.springframework.cr.smoketest.support.junit.ApplicationTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import static org.assertj.core.api.Assertions.assertThat;
@ApplicationTest
class WebMvcApplicationTests {
@Test
void stringResponseBody(WebTestClient client) {
client.get()
.exchange()
.expectStatus()
.isOk()
.expectBody()
.consumeWith((result) -> assertThat(new String(result.getResponseBodyContent()))
.isEqualTo("Hello from Spring MVC and Tomcat"));
}
@Test
void resourceInStatic(WebTestClient client) {
client.get()
.uri("foo.html")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.consumeWith((result) -> assertThat(new String(result.getResponseBodyContent())).isEqualTo("Foo"));
}
}

View File

@@ -0,0 +1,13 @@
package com.example.webmvc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebMvcApplication {
public static void main(String[] args) {
SpringApplication.run(WebMvcApplication.class, args);
}
}

View File

@@ -0,0 +1,14 @@
package com.example.webmvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebMvcController {
@GetMapping("/")
public String hello() {
return "Hello from Spring MVC and Tomcat";
}
}

View File

@@ -0,0 +1 @@
Foo

View File

@@ -0,0 +1,40 @@
package com.example.webmvc;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
class RandomPortTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate template;
@Test
void check() {
assertThat(this.port).isNotZero();
}
@Test
void stringResponseBody() {
ResponseEntity<String> response = this.template.getForEntity("/", String.class);
assertThat(response.getBody()).isEqualTo("Hello from Spring MVC and Tomcat");
}
@Test
void resourceInStatic() {
ResponseEntity<String> response = this.template.getForEntity("/foo.html", String.class);
assertThat(response.getBody()).isEqualTo("Foo");
}
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2g
kotlinVersion=1.8.22
javaFormatVersion=0.0.38
springBootVersion=3.2.0-SNAPSHOT

View File

@@ -0,0 +1,47 @@
plugins {
id "checkstyle"
id "io.spring.javaformat"
id "java-gradle-plugin"
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
}
gradlePlugin {
plugins {
crSmokeTestPlugin {
id = "org.springframework.cr.smoke-test"
implementationClass = "org.springframework.cr.gradle.CrSmokeTestPlugin"
}
crSmokeTestAggregatorPlugin {
id = "org.springframework.cr.smoke-test-aggregator"
implementationClass = "org.springframework.cr.gradle.CrSmokeTestAggregatorPlugin"
}
}
}
new File(rootDir.parentFile.parentFile.parentFile, "gradle.properties").withInputStream {
def properties = new Properties()
properties.load(it)
properties.each { key, value ->
if (key.endsWith("Version")) {
project.ext.set(key, value)
}
}
}
dependencies {
checkstyle("io.spring.javaformat:spring-javaformat-checkstyle:$javaFormatVersion")
compileOnly("io.spring.javaformat:spring-javaformat-gradle-plugin:$javaFormatVersion")
compileOnly("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion")
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
implementation("com.avast.gradle:gradle-docker-compose-plugin:0.16.8")
}
sourceCompatibility = "17"
targetCompatibility = "17"

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="com.puppycrawl.tools.checkstyle.Checker">
<module name="io.spring.javaformat.checkstyle.SpringChecks" />
</module>

View File

@@ -0,0 +1,16 @@
pluginManagement {
new File(rootDir.parentFile.parentFile.parentFile, "gradle.properties").withInputStream {
def properties = new Properties()
properties.load(it)
properties.each { key, value ->
if (key.endsWith("Version")) {
gradle.extensions.extraProperties.set(key, value)
}
}
}
plugins {
id "io.spring.javaformat" version gradle.extensions.extraProperties.get("javaFormatVersion")
}
}
rootProject.name="cr-smoke-test-plugin"

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.tasks.TaskProvider;
import org.springframework.cr.gradle.tasks.UpdateConcoursePipeline;
import org.springframework.cr.gradle.tasks.UpdateStatusPage;
/**
* Plugin for a project that aggregates the smoke tests to provide status and a CI
* pipeline.
*
* @author Andy Wilkinson
* @author Sebastien Deleuze
*/
public class CrSmokeTestAggregatorPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
Configuration smokeTests = project.getConfigurations().create("smokeTests");
TaskProvider<UpdateStatusPage> updateStatusPage = project.getTasks()
.register("updateStatusPage", UpdateStatusPage.class, (task) -> {
task.setSmokeTests(smokeTests);
task.getOutputFile().set(project.file("STATUS.adoc"));
});
TaskProvider<UpdateConcoursePipeline> updateConcoursePipeline = project.getTasks()
.register("updateConcoursePipeline", UpdateConcoursePipeline.class, (task) -> {
task.setSmokeTests(smokeTests);
task.getOutputFile().set(project.file("ci/smoke-tests.yml"));
});
project.getTasks()
.register("updateInfrastructure", (task) -> task.dependsOn(updateStatusPage, updateConcoursePipeline));
}
}

View File

@@ -0,0 +1,251 @@
/*
* Copyright 2022 the original author or 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.
*/
package org.springframework.cr.gradle;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
import com.avast.gradle.dockercompose.ComposeExtension;
import com.avast.gradle.dockercompose.ComposeSettings;
import org.gradle.api.JavaVersion;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.dsl.DependencyHandler;
import org.gradle.api.file.Directory;
import org.gradle.api.file.RegularFile;
import org.gradle.api.plugins.JavaBasePlugin;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.tasks.testing.Test;
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile;
import org.springframework.boot.gradle.plugin.SpringBootPlugin;
import org.springframework.boot.gradle.tasks.bundling.BootJar;
import org.springframework.cr.gradle.dsl.CrSmokeTestExtension;
import org.springframework.cr.gradle.tasks.AppTest;
import org.springframework.cr.gradle.tasks.DescribeSmokeTests;
import org.springframework.cr.gradle.tasks.StartApplication;
import org.springframework.cr.gradle.tasks.StartJvmApplication;
import org.springframework.cr.gradle.tasks.StopApplication;
/**
* {@link Plugin} for a checkpoint/restore smoke test project. Configures an {@code appTest} source set
* and tasks for running the contained tests against the application running on the JVM
* and as a native image.
*
* @author Andy Wilkinson
* @author Moritz Halbritter
* @author Sebastien Deleuze
*/
public class CrSmokeTestPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins()
.withType(JavaPlugin.class, (javaPlugin) -> project.getPlugins()
.withType(SpringBootPlugin.class, (bootPlugin) -> configureBootJavaProject(project)));
}
private void configureBootJavaProject(Project project) {
CrSmokeTestExtension extension = project.getExtensions()
.create("crSmokeTest", CrSmokeTestExtension.class, project);
extension.getWebApplication().convention(false);
JavaPluginExtension javaExtension = project.getExtensions().getByType(JavaPluginExtension.class);
SourceSet appTest = javaExtension.getSourceSets().create("appTest");
javaExtension.setSourceCompatibility(JavaVersion.VERSION_17);
javaExtension.setTargetCompatibility(JavaVersion.VERSION_17);
if (project.hasProperty("fromMavenLocal")) {
String fromMavenLocal = project.property("fromMavenLocal").toString();
Stream<String> includedGroups = Stream.of(fromMavenLocal.split(","));
project.getRepositories()
.mavenLocal(
(mavenLocal) -> mavenLocal.content((content) -> includedGroups.forEach(content::includeGroup)));
}
project.getRepositories().mavenCentral();
project.getRepositories().maven((repo) -> {
repo.setName("Spring Milestone");
repo.setUrl("https://repo.spring.io/milestone");
});
project.getRepositories().maven((repo) -> {
repo.setName("Spring Snapshot");
repo.setUrl("https://repo.spring.io/snapshot");
});
configureAppTests(project, extension, appTest);
configureTests(project);
configureKotlin(project, javaExtension);
Configuration smokeTests = project.getConfigurations().create("smokeTests");
TaskProvider<DescribeSmokeTests> describeSmokeTests = project.getTasks()
.register("describeSmokeTests", DescribeSmokeTests.class);
describeSmokeTests.configure((task) -> {
task.getOutputDirectory().set(project.getLayout().getBuildDirectory().dir(task.getName()));
task.setAppTests(appTest.getAllSource());
task.setTests(javaExtension.getSourceSets().getByName(SourceSet.TEST_SOURCE_SET_NAME).getAllSource());
});
project.artifacts((artifacts) -> artifacts.add(smokeTests.getName(), describeSmokeTests));
DependencyHandler dependencies = project.getRootProject().getDependencies();
dependencies.add("smokeTests",
dependencies.project(Map.of("path", project.getPath(), "configuration", smokeTests.getName())));
}
private void configureAppTests(Project project, CrSmokeTestExtension extension, SourceSet appTest) {
configureJvmAppTests(project, appTest, extension);
}
private void configureTests(Project project) {
project.getTasks().named(JavaPlugin.TEST_TASK_NAME, Test.class).configure((test) -> {
test.useJUnitPlatform();
});
}
private void configureKotlin(Project project, JavaPluginExtension javaExtension) {
project.getTasks()
.withType(KotlinJvmCompile.class)
.configureEach((kotlinCompile) -> kotlinCompile.getKotlinOptions()
.setJvmTarget(javaExtension.getTargetCompatibility().toString()));
}
private void configureJvmAppTests(Project project, SourceSet sourceSet, CrSmokeTestExtension extension) {
Provider<RegularFile> archiveFile = project.getTasks()
.named(SpringBootPlugin.BOOT_JAR_TASK_NAME, BootJar.class)
.flatMap(BootJar::getArchiveFile);
configureTasks(project, sourceSet, ApplicationType.JVM, archiveFile, extension);
}
private TaskProvider<AppTest> configureTasks(Project project, SourceSet appTest, ApplicationType type,
Provider<RegularFile> nativeImage, CrSmokeTestExtension extension) {
Provider<Directory> outputDirectory = project.getLayout()
.getBuildDirectory()
.dir(type.name().toLowerCase() + "App");
TaskProvider<? extends StartApplication> startTask = createStartApplicationTask(project, type, nativeImage,
outputDirectory, extension);
TaskProvider<StopApplication> stopTask = createStopApplicationTask(project, type, startTask);
TaskProvider<AppTest> appTestTask = createAppTestTask(project, appTest, type, startTask, stopTask);
configureDockerComposeIfNecessary(project, type, startTask, appTestTask, stopTask);
return appTestTask;
}
private void configureDockerComposeIfNecessary(Project project, ApplicationType type,
TaskProvider<? extends StartApplication> startTask, TaskProvider<AppTest> appTestTask,
TaskProvider<StopApplication> stopTask) {
if (!project.file("docker-compose.yml").canRead()) {
return;
}
project.getPlugins().apply("com.avast.gradle.docker-compose");
ComposeExtension compose = project.getExtensions().getByType(ComposeExtension.class);
ComposeSettings composeSettings = compose.nested(type.name().toLowerCase());
String composeUpTaskName = composeSettings.getNestedName() + "ComposeUp";
String composeDownTaskName = composeSettings.getNestedName() + "ComposeDown";
project.getTasks()
.named(composeUpTaskName)
.configure((composeUp) -> composeUp.finalizedBy(composeDownTaskName));
startTask.configure((start) -> {
start.dependsOn(composeUpTaskName);
start.getInternalEnvironment().putAll(environment(project, composeSettings));
});
appTestTask
.configure((appTest) -> appTest.getInternalEnvironment().putAll(environment(project, composeSettings)));
project.getTasks().named(composeDownTaskName).configure((composeDown) -> composeDown.mustRunAfter(stopTask));
}
private Provider<Map<String, String>> environment(Project project, ComposeSettings settings) {
return project.provider(() -> {
Map<String, String> environment = new HashMap<>();
settings.getServicesInfos().forEach((serviceName, service) -> {
String name = serviceName.toUpperCase(Locale.ENGLISH);
environment.put(name + "_HOST", service.getHost());
service.getTcpPorts()
.forEach((source, target) -> environment.put(name + "_PORT_" + source, Integer.toString(target)));
});
return environment;
});
}
private TaskProvider<StopApplication> createStopApplicationTask(Project project, ApplicationType type,
TaskProvider<? extends StartApplication> startTask) {
String taskName = switch (type) {
case JVM -> "stopApp";
};
TaskProvider<StopApplication> stopTask = project.getTasks()
.register(taskName, StopApplication.class, (stop) -> {
stop.getPidFile().set(startTask.flatMap(StartApplication::getPidFile));
stop.setDescription("Stops the " + type.description + " application.");
});
startTask.configure((start) -> start.finalizedBy(stopTask));
return stopTask;
}
private TaskProvider<? extends StartApplication> createStartApplicationTask(Project project, ApplicationType type,
Provider<RegularFile> applicationBinary, Provider<Directory> outputDirectory,
CrSmokeTestExtension extension) {
String taskName = switch (type) {
case JVM -> "startApp";
};
return project.getTasks().register(taskName, type.startTaskType, (start) -> {
start.getApplicationBinary().set(applicationBinary);
start.getOutputDirectory().set(outputDirectory);
start.setDescription("Starts the " + type.description + " application.");
start.getWebApplication().convention(extension.getWebApplication());
});
}
private TaskProvider<AppTest> createAppTestTask(Project project, SourceSet source, ApplicationType type,
TaskProvider<? extends StartApplication> startTask, TaskProvider<StopApplication> stopTask) {
String taskName = switch (type) {
case JVM -> "appTest";
};
TaskProvider<AppTest> appTestTask = project.getTasks().register(taskName, AppTest.class, (task) -> {
task.dependsOn(startTask);
task.useJUnitPlatform();
task.setTestClassesDirs(source.getOutput().getClassesDirs());
task.setClasspath(source.getRuntimeClasspath());
task.getInputs()
.file(startTask.flatMap(StartApplication::getApplicationBinary))
.withPropertyName("applicationBinary");
task.systemProperty("org.springframework.cr.smoketest.standard-output",
startTask.get().getOutputFile().get().getAsFile().getAbsolutePath());
task.finalizedBy(stopTask);
task.setDescription("Runs the app test suite against the " + type.description + " application.");
task.setGroup(JavaBasePlugin.VERIFICATION_GROUP);
task.dependsOn(startTask);
});
stopTask.configure((stop) -> stop.mustRunAfter(appTestTask));
project.getTasks().named(JavaBasePlugin.CHECK_TASK_NAME).configure((check) -> check.dependsOn(appTestTask));
return appTestTask;
}
private enum ApplicationType {
JVM("JVM", StartJvmApplication.class);
private final String description;
private final Class<? extends StartApplication> startTaskType;
ApplicationType(String name, Class<? extends StartApplication> startTaskType) {
this.description = name;
this.startTaskType = startTaskType;
}
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.dsl;
import javax.inject.Inject;
import org.gradle.api.Project;
import org.gradle.api.provider.Property;
/**
* DSL extension for configuring checkpoint/restore smoke tests.
*
* @author Andy Wilkinson
* @author Sebastien Deleuze
*/
public class CrSmokeTestExtension {
private final Property<Boolean> webApplication;
@Inject
public CrSmokeTestExtension(Project project) {
this.webApplication = project.getObjects().property(Boolean.class);
}
/**
* Whether the application under test is a web application.
* @return whether the application under test is a web application.
*/
public Property<Boolean> getWebApplication() {
return this.webApplication;
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2023 the original author or 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.
*/
/**
* DSL for smoke testing of applications using checkpoint/restore.
*/
package org.springframework.cr.gradle.dsl;

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2023 the original author or 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.
*/
/**
* Gradle plugin for smoke testing of applications using checkpoint/restore.
*/
package org.springframework.cr.gradle;

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.FileCollection;
import org.gradle.api.tasks.InputFiles;
/**
* Base class for tasks that work with smoke tests.
*
* @author Andy Wilkinson
* @author Sebastien Deleuze
*/
public abstract class AbstractSmokeTestsTask extends DefaultTask {
private FileCollection smokeTestDescriptions;
@InputFiles
public FileCollection getSmokeTests() {
return this.smokeTestDescriptions;
}
public void setSmokeTests(FileCollection smokeTests) {
this.smokeTestDescriptions = smokeTests;
}
Map<String, SortedSet<SmokeTest>> smokeTests() {
List<SmokeTest> smokeTests = this.smokeTestDescriptions.getFiles()
.stream()
.map(this::readProperties)
.map(SmokeTest::new)
.collect(Collectors.toList());
Map<String, SortedSet<SmokeTest>> groupedSmokeTests = new TreeMap<>();
for (SmokeTest smokeTest : smokeTests) {
groupedSmokeTests
.computeIfAbsent(smokeTest.group(),
(group) -> new TreeSet<>((one, two) -> one.name().compareTo(two.name())))
.add(smokeTest);
}
return groupedSmokeTests;
}
private Properties readProperties(File smokeTest) {
Properties properties = new Properties();
try (InputStream input = new FileInputStream(new File(smokeTest, "smoke-tests.properties"))) {
properties.load(input);
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
return properties;
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.testing.Test;
/**
* Custom {@link Test} task for app tests.
*
* @author Moritz Halbritter
*/
public abstract class AppTest extends Test {
@Internal
public abstract MapProperty<String, String> getInternalEnvironment();
@Override
public void executeTests() {
environment(getInternalEnvironment().get());
super.executeTests();
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import org.gradle.api.DefaultTask;
import org.gradle.api.Task;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileCollection;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.TaskAction;
/**
* {@link Task} that describes a project's smoke tests.
*
* @author Andy Wilkinson
*/
public abstract class DescribeSmokeTests extends DefaultTask {
private FileCollection appTests;
private FileCollection tests;
public DescribeSmokeTests() {
getSmokeTestsDescription().put("appTests",
getProject().provider(() -> Boolean.toString(!this.appTests.isEmpty())));
getSmokeTestsDescription().put("tests", getProject().provider(() -> Boolean.toString(!this.tests.isEmpty())));
getSmokeTestsDescription().put("path", getProject().provider(getProject()::getPath));
getSmokeTestsDescription().put("group", getProject().provider(getProject().getParent()::getName));
getSmokeTestsDescription().put("name", getProject().provider(getProject()::getName));
}
@Input
abstract MapProperty<String, String> getSmokeTestsDescription();
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
@InputFiles
public FileCollection getAppTests() {
return this.appTests;
}
public void setAppTests(FileCollection appTests) {
this.appTests = appTests;
}
@InputFiles
public FileCollection getTests() {
return this.tests;
}
public void setTests(FileCollection tests) {
this.tests = tests;
}
@TaskAction
void describeSmokeTests() throws IOException {
File smokeTests = getOutputDirectory().file("smoke-tests.properties").get().getAsFile();
List<String> lines = getSmokeTestsDescription().get()
.entrySet()
.stream()
.map((entry) -> entry.getKey() + "=" + entry.getValue())
.sorted()
.toList();
Files.write(smokeTests.toPath(), lines);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.util.Properties;
/**
* A smoke test.
*
* @author Andy Wilkinson
* @param name name of the smoke test
* @param group group of the smoke test
* @param path path of the smoke test project
* @param tests whether the smoke test contains any unit tests
* @param appTests whether the smoke test contains any app tests
*/
record SmokeTest(String name, String group, String path, boolean tests, boolean appTests) {
SmokeTest(Properties properties) {
this(properties.getProperty("name"), properties.getProperty("group"), properties.getProperty("path"),
Boolean.valueOf(properties.getProperty("tests")), Boolean.valueOf(properties.getProperty("appTests")));
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.gradle.api.DefaultTask;
import org.gradle.api.Task;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.TaskAction;
/**
* Base class for a {@link Task} that starts and application.
*
* @author Andy WilkinsonO
*/
public abstract class StartApplication extends DefaultTask {
public StartApplication() {
getPidFile().convention(getOutputDirectory().map((dir) -> dir.file("pid")));
getOutputFile().convention(getOutputDirectory().map((dir) -> dir.file("output.txt")));
getErrorFile().convention(getOutputDirectory().map((dir) -> dir.file("error.txt")));
}
/**
* The application's binary.
* @return the application's binary
*/
@InputFile
public abstract RegularFileProperty getApplicationBinary();
/**
* Whether the application to be started is a web application. When it is, the
* application is started with {@code -Dserver.port=0} to prevent port clashes.
* @return whether the application is a web application
*/
@Input
public abstract Property<Boolean> getWebApplication();
/**
* The directory to which output should be written. Controls the location of the pid,
* output, and error files.
* @return the output directory
*/
@Internal
public abstract DirectoryProperty getOutputDirectory();
/**
* The file to which the application's pid should be written.
* @return the pid file
*/
@Internal
public abstract RegularFileProperty getPidFile();
/**
* The file to which the application's redirected output stream should be written.
* @return the output file
*/
@Internal
public abstract RegularFileProperty getOutputFile();
/**
* The file to which the application's redirected error stream should be written.
* @return the error file
*/
@Internal
public abstract RegularFileProperty getErrorFile();
@Internal
public abstract MapProperty<String, String> getInternalEnvironment();
@TaskAction
void startApplication() throws IOException {
getOutputDirectory().getAsFile().get().mkdirs();
File redirectedError = getErrorFile().get().getAsFile();
File redirectedOutput = getOutputFile().get().getAsFile();
Path pid = getPidFile().get().getAsFile().toPath();
Files.deleteIfExists(redirectedError.toPath());
Files.deleteIfExists(redirectedOutput.toPath());
Files.deleteIfExists(pid);
ProcessBuilder processBuilder = prepareProcessBuilder(new ProcessBuilder()).redirectError(redirectedError)
.redirectOutput(redirectedOutput);
processBuilder.environment().putAll(getInternalEnvironment().get());
Process process = processBuilder.start();
Files.write(pid, List.of(Long.toString(process.pid())));
}
protected abstract ProcessBuilder prepareProcessBuilder(ProcessBuilder processBuilder);
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.gradle.api.Task;
import org.gradle.internal.jvm.Jvm;
/**
* {@link Task} to start an application on the JVM.
*
* @author Andy Wilkinson
*/
public abstract class StartJvmApplication extends StartApplication {
@Override
protected ProcessBuilder prepareProcessBuilder(ProcessBuilder processBuilder) {
File executable = Jvm.current().getJavaExecutable();
List<String> command = new ArrayList<>();
command.add(executable.getAbsolutePath());
if (getWebApplication().get()) {
command.add("-Dserver.port=0");
}
command.add("-jar");
command.add(getApplicationBinary().get().getAsFile().getAbsolutePath());
return processBuilder.command(command);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.gradle.api.DefaultTask;
import org.gradle.api.Task;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.TaskAction;
/**
* {@link Task} to stop a {@link StartApplication} previously started application.
*
* @author Andy Wilkinson
*/
public class StopApplication extends DefaultTask {
private final RegularFileProperty pidFile;
public StopApplication() {
this.pidFile = getProject().getObjects().fileProperty();
}
/**
* The file to which the application's pid has been written.
* @return the pid file
*/
@InputFile
public RegularFileProperty getPidFile() {
return this.pidFile;
}
@TaskAction
void stopJvmApp() throws IOException {
Path pidPath = this.pidFile.get().getAsFile().toPath();
List<String> lines = Files.readAllLines(pidPath);
if (lines.size() == 1) {
long pid = Long.parseLong(lines.get(0));
ProcessHandle.of(pid).ifPresent((process) -> process.destroy());
Files.delete(pidPath);
}
else {
System.out.println("No PID. Assuming process is stopped");
}
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
/**
* Task to update the Concourse pipeline for the smoke tests.
*
* @author Andy Wilkinson
*/
public abstract class UpdateConcoursePipeline extends AbstractSmokeTestsTask {
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
void updatePipeline() {
File outputFile = getOutputFile().getAsFile().get();
try (PrintWriter writer = new PrintWriter(new FileWriter(outputFile))) {
writer.println("groups:");
smokeTests().forEach((group, tests) -> {
writer.println(" - name: " + group);
writer.println(" smoke_tests:");
tests.forEach((smokeTest) -> {
writer.println(" - name: " + smokeTest.name());
writer.println(" app_test: " + smokeTest.appTests());
writer.println(" test: " + smokeTest.tests());
});
});
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2023 the original author or 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.
*/
package org.springframework.cr.gradle.tasks;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
/**
* Task to update an Asciidoctor status page for the smoke tests.
*
* @author Andy Wilkinson
* @author Sebastien Deleuze
*/
public abstract class UpdateStatusPage extends AbstractSmokeTestsTask {
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
void updateStatusPage() throws IOException {
List<String> lines = new ArrayList<>();
lines.add("= Smoke Tests Status");
lines.add(":toc:");
lines.add(":toc-title: Projects");
lines.add("");
smokeTests().forEach((group, tests) -> {
lines.add("== " + capitalize(group));
lines.add("");
lines.add("[%header,cols=\"" + (TestType.values().length + 1) + "\"]");
lines.add("|===");
lines.add("h|Smoke Test");
for (TestType testType : TestType.values()) {
lines.add("h|" + testType.taskName());
}
lines.add("");
for (SmokeTest test : tests) {
lines.add("|" + test.name());
for (TestType testType : TestType.values()) {
lines.add("|" + testType.badge(test));
}
lines.add("");
}
lines.add("|===");
lines.add("");
});
Files.write(getOutputFile().get().getAsFile().toPath(), lines);
}
private String capitalize(String input) {
StringBuffer buffer = new StringBuffer(input.length());
for (char c : input.toCharArray()) {
buffer.append(buffer.isEmpty() ? Character.toUpperCase(c) : c);
}
return buffer.toString();
}
private enum TestType {
APP_TEST(SmokeTest::appTests, "-app-test", "appTest"),
TEST(SmokeTest::tests, "-test", "test");
private final Predicate<SmokeTest> predicate;
private final String urlSuffix;
private final String taskName;
TestType(Predicate<SmokeTest> predicate, String suffix, String taskName) {
this.predicate = predicate;
this.urlSuffix = suffix;
this.taskName = taskName;
}
String badge(SmokeTest smokeTest) {
if (!this.predicate.test(smokeTest)) {
return "";
}
return "image:" + badgeUrl(smokeTest.name(), this.urlSuffix) + "[link="
+ jobUrl(smokeTest.name(), this.urlSuffix) + "]";
}
String taskName() {
return this.taskName;
}
private String badgeUrl(String name, String suffix) {
return "https://ci.spring.io/api/v1/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/"
+ name + suffix + "/badge";
}
private String jobUrl(String name, String suffix) {
return "https://ci.spring.io/teams/spring-checkpoint-restore-smoke-tests/pipelines/spring-checkpoint-restore-smoke-tests-3.2.x/jobs/"
+ name + suffix;
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2023 the original author or 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.
*/
/**
* Tasks for smoke testing of applications using checkpoint/restore.
*/
package org.springframework.cr.gradle.tasks;

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

244
gradlew vendored Executable file
View File

@@ -0,0 +1,244 @@
#!/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.
#
##############################################################################
#
# 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/subprojects/plugins/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##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# 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"'
# 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# 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
which java >/dev/null 2>&1 || 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
# 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=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=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
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# 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" "$@"

92
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

33
settings.gradle Normal file
View File

@@ -0,0 +1,33 @@
pluginManagement {
includeBuild "gradle/plugins/cr-smoke-test-plugin"
repositories {
gradlePluginPortal()
maven {
url "https://repo.spring.io/snapshot"
}
maven {
url 'https://repo.spring.io/milestone'
}
maven {
url "https://repo.spring.io/release"
content {
includeGroup "io.spring.ge.conventions"
}
}
}
}
plugins {
id "com.gradle.enterprise" version "3.12.6"
id "io.spring.ge.conventions" version "0.0.13"
}
rootProject.name="spring-checkpoint-restore-smoke-tests"
include "cr-smoke-test-support"
["framework"].each { group ->
file(group).eachDirMatch(~/[a-z].*/) { smokeTest ->
include "$group:${smokeTest.name}"
}
}