diff --git a/.github/workflows/release-publish-docs.yml b/.github/workflows/release-publish-docs.yml index b4820647804..73f1ae939dc 100644 --- a/.github/workflows/release-publish-docs.yml +++ b/.github/workflows/release-publish-docs.yml @@ -25,7 +25,7 @@ permissions: { } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JAVA_DISTRIBUTION: liberica - JAVA_VERSION: 21.0.7 + JAVA_VERSION: 21.0.12 TARGET_BRANCH: ${{ github.ref_name }} VERSION: ${{ inputs.version }} jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6762cb9b84..b044f6d0830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GRAILS_PUBLISH_RELEASE: 'true' JAVA_DISTRIBUTION: liberica - JAVA_VERSION: 21.0.7 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc and verification container + JAVA_VERSION: 21.0.12 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc and verification container JAVA_VERSION_MICRONAUT: 25.0.3 # the Grails-Micronaut "island" (grails-micronaut, grails-micronaut-bom) is built against Micronaut 5 which targets JVM 25 bytecode. Keep this synced with the secondary JDK installed in etc/bin/Dockerfile and the JDK_25_HOME branch in etc/bin/verify-reproducible.sh. PROJECT_DESC: > Grails is a powerful Groovy-based web application framework for the JVM, diff --git a/.sdkmanrc b/.sdkmanrc index 508f113b0c1..8a8e9095b8d 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -5,7 +5,7 @@ # $JAVA_VERSION_MICRONAUT in release.yml; for local verification, install that JDK 25 # alongside this one (sdk install java -librca) and follow the dual-JDK # instructions in RELEASE.md "Manual Verification: Reproducible Jar Files". -java=21.0.7-librca +java=21.0.12-librca # Keep gradle version synced with gradle.properties (gradleToolingApiVersion). # Update the gradle-bootstrap project to propagate the version to all gradle-wrapper.properties files. gradle=9.6.0 diff --git a/dependencies.gradle b/dependencies.gradle index 29bc2978822..ad18672d592 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -126,8 +126,8 @@ ext { 'sitemesh.version' : '2.6.0', 'scribejava.version' : '8.3.3', 'spock.version' : '2.4-groovy-5.0', - 'starter-sitemesh.version' : '3.3.0-M3', - 'spring-webmvc-sitemesh.version': '3.3.0-M3', + 'starter-sitemesh.version' : '3.3.0-SNAPSHOT', + 'spring-webmvc-sitemesh.version': '3.3.0-SNAPSHOT', // Spring Boot 4 no longer manages spring-retry; pin it here so the // grails-shell-cli SpringRetryCompilerAutoConfiguration's unversioned // reference resolves and consumer apps using @Retryable get a known version. diff --git a/etc/bin/Dockerfile b/etc/bin/Dockerfile index fe376a6da81..345396ad874 100644 --- a/etc/bin/Dockerfile +++ b/etc/bin/Dockerfile @@ -17,7 +17,7 @@ # run this from the root of the project # `docker build -t grails:testing -f etc/bin/Dockerfile . && docker run -it --rm -v $(pwd):/home/groovy/project grails:testing bash` # Keep java version synced with .sdkmanrc and .github/workflows/release.yml ($JAVA_VERSION) -FROM bellsoft/liberica-openjdk-debian:21.0.7 +FROM bellsoft/liberica-openjdk-debian:21.0.12 USER root RUN apt-get update && apt-get install -y ca-certificates curl unzip coreutils libdigest-sha-perl gpg vim sudo psmisc locales groovy rsync nano diff --git a/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java b/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java new file mode 100644 index 00000000000..840b2b7c54b --- /dev/null +++ b/grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.aot; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.asm.ClassReader; +import org.springframework.asm.ClassVisitor; +import org.springframework.asm.ConstantDynamic; +import org.springframework.asm.FieldVisitor; +import org.springframework.asm.Handle; +import org.springframework.asm.Label; +import org.springframework.asm.MethodVisitor; +import org.springframework.asm.SpringAsmInfo; +import org.springframework.asm.Type; +import org.springframework.util.ClassUtils; + +/** + * Decides whether a type found by scanning can be registered for reflection. + * + *

Registering one that cannot be loaded here does not degrade at run time -- it fails the build, + * because the image analysis parses what it is asked to keep. The framework compiles against + * optional integrations, so a scan of its own packages finds classes an application need not be able + * to load: the JSP support without the JSP API, the GSP compiler's task without Ant, a datastore + * without its driver.

+ * + *

Two questions are asked, because either alone lets one of those through. Whether the type and + * the class declaring it load covers a closure whose enclosing class extends something absent, which + * the closure's own bytecode need not name -- it reaches it through invokedynamic. Whether the types + * its bytecode names load covers the opposite case, a class that loads cleanly while a method body + * names something absent, because loading resolves signatures and not bodies.

+ * + * @since 8.0 + */ +public final class RegistrableTypes { + + private RegistrableTypes() { + } + + /** + * Whether the type, and the class declaring it, load here. + * + * @param className the binary name of the type + * @param classLoader the loader to resolve against + * @return whether the type can be registered + */ + public static boolean loads(String className, @Nullable ClassLoader classLoader) { + int declaring = className.indexOf('$'); + if (declaring > 0 && !ClassUtils.isPresent(className.substring(0, declaring), classLoader)) { + return false; + } + return ClassUtils.isPresent(className, classLoader); + } + + /** + * Whether every type named in the given bytecode loads here, following the declaration and the + * bodies of the methods. + * + * @param bytecode the class file to read; closed by this method + * @param classLoader the loader to resolve against + * @return whether the type can be registered + */ + public static boolean referencesLoad(InputStream bytecode, @Nullable ClassLoader classLoader) { + Set referenced = new LinkedHashSet<>(); + try (InputStream input = bytecode) { + new ClassReader(input).accept(new ReferenceCollector(referenced), + ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + catch (IOException | RuntimeException ex) { + return false; + } + for (String name : referenced) { + if (!ClassUtils.isPresent(name, classLoader)) { + return false; + } + } + return true; + } + + /** Collects the types a class names, in its own declaration and in the bodies of its methods. */ + private static final class ReferenceCollector extends ClassVisitor { + + private final Set referenced; + + private ReferenceCollector(Set referenced) { + super(SpringAsmInfo.ASM_VERSION); + this.referenced = referenced; + } + + private void add(@Nullable String internalName) { + if (internalName == null) { + return; + } + if (internalName.startsWith("[")) { + // an array names its element type, which is the class that has to be there + addType(Type.getType(internalName)); + return; + } + String className = ClassUtils.convertResourcePathToClassName(internalName); + // the JDK is always present, and skipping it keeps this to the classes that can be absent + if (!className.startsWith("java.") && !className.startsWith("jdk.")) { + referenced.add(className); + } + } + + /** Adds a type, reducing an array to the element type it is an array of. */ + private void addType(@Nullable Type type) { + if (type == null) { + return; + } + Type element = type; + while (element.getSort() == Type.ARRAY) { + element = element.getElementType(); + } + if (element.getSort() == Type.OBJECT) { + add(element.getInternalName()); + } + } + + /** Adds every type a method descriptor names: what it takes and what it gives back. */ + private void addMethodDescriptor(@Nullable String descriptor) { + if (descriptor == null) { + return; + } + for (Type argument : Type.getArgumentTypes(descriptor)) { + addType(argument); + } + addType(Type.getReturnType(descriptor)); + } + + /** Adds whichever constant carries a type: a class literal, a handle, or a method type. */ + private void addConstant(@Nullable Object constant) { + if (constant instanceof Type type) { + if (type.getSort() == Type.METHOD) { + addMethodDescriptor(type.getDescriptor()); + } + else { + addType(type); + } + } + else if (constant instanceof Handle handle) { + add(handle.getOwner()); + addMethodDescriptor(handle.getDesc()); + } + else if (constant instanceof ConstantDynamic dynamic) { + addType(Type.getType(dynamic.getDescriptor())); + add(dynamic.getBootstrapMethod().getOwner()); + for (int i = 0; i < dynamic.getBootstrapMethodArgumentCount(); i++) { + addConstant(dynamic.getBootstrapMethodArgument(i)); + } + } + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, + String[] interfaces) { + add(superName); + if (interfaces != null) { + for (String each : interfaces) { + add(each); + } + } + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, + Object value) { + addType(Type.getType(descriptor)); + return null; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + addMethodDescriptor(descriptor); + if (exceptions != null) { + for (String each : exceptions) { + add(each); + } + } + return new MethodVisitor(SpringAsmInfo.ASM_VERSION) { + @Override + public void visitTypeInsn(int opcode, String type) { + add(type); + } + + @Override + public void visitMethodInsn(int opcode, String owner, String methodName, + String methodDescriptor, boolean isInterface) { + add(owner); + addMethodDescriptor(methodDescriptor); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String fieldName, + String fieldDescriptor) { + add(owner); + addType(Type.getType(fieldDescriptor)); + } + + @Override + public void visitLdcInsn(Object value) { + // a class literal, which names a type without ever calling anything on it + addConstant(value); + } + + @Override + public void visitInvokeDynamicInsn(String methodName, String methodDescriptor, + Handle bootstrap, Object... arguments) { + // how a Groovy call site names what it dispatches on + addMethodDescriptor(methodDescriptor); + addConstant(bootstrap); + for (Object argument : arguments) { + addConstant(argument); + } + } + + @Override + public void visitMultiANewArrayInsn(String descriptor, int dimensions) { + addType(Type.getType(descriptor)); + } + + @Override + public void visitTryCatchBlock(Label start, Label end, Label handler, String type) { + add(type); + } + }; + } + } +} diff --git a/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy b/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy new file mode 100644 index 00000000000..3319fa77ddc --- /dev/null +++ b/grails-common/src/test/groovy/org/grails/aot/RegistrableTypesSpec.groovy @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.aot + +import org.springframework.asm.ClassWriter +import org.springframework.asm.Handle +import org.springframework.asm.Label +import org.springframework.asm.MethodVisitor +import org.springframework.asm.Opcodes +import org.springframework.asm.Type + +import spock.lang.Specification + +/** + * Covers which scanned types may be registered for reflection. Registering one that cannot be loaded + * fails the image build rather than degrading at run time, which is why this is asked at all. + */ +class RegistrableTypesSpec extends Specification { + + ClassLoader loader = getClass().classLoader + + private InputStream bytecodeOf(Class type) { + loader.getResourceAsStream(type.name.replace('.', '/') + '.class') + } + + void 'a type that loads may be registered'() { + expect: + RegistrableTypes.loads('java.lang.String', loader) + } + + void 'a type that is absent may not'() { + expect: + !RegistrableTypes.loads('com.example.NotOnTheClasspath', loader) + } + + void 'a nested type whose declaring class is absent may not'() { + expect: 'this is the closure whose enclosing class extends something absent, which the ' + + 'closure reaches through invokedynamic and so never names itself' + !RegistrableTypes.loads('com.example.Missing$_run_closure1', loader) + } + + void 'a nested type whose declaring class loads may be'() { + expect: + RegistrableTypes.loads(Outer.Inner.name, loader) + } + + void 'bytecode naming only types that load may be registered'() { + expect: + RegistrableTypes.referencesLoad(bytecodeOf(Outer), loader) + } + + void 'bytecode is rejected when it cannot be read'() { + expect: + !RegistrableTypes.referencesLoad(new ByteArrayInputStream('not a class'.bytes), loader) + } + + void 'a class loader without the framework on it accepts none of its types'() { + given: 'a bootstrap-only loader, which still has the JDK but nothing else' + ClassLoader empty = new URLClassLoader(new URL[0], null) + + expect: + !RegistrableTypes.loads(RegistrableTypes.name, empty) + RegistrableTypes.loads('java.lang.String', empty) + } + + void 'bytecode is rejected wherever the absent type is named'() { + expect: 'each of these names it once and nowhere else, so each stands or falls on its own' + !RegistrableTypes.referencesLoad(namingAbsent(shape), loader) + + where: + shape << Shape.values() + } + + void 'the same shapes naming a type that loads are accepted'() { + expect: 'so the rejections above are the absent type and not the shape it was named in' + RegistrableTypes.referencesLoad(naming('java/lang/Number', shape), loader) + + where: + shape << Shape.values() + } + + /** The places a class file can name a type, one per shape. */ + private enum Shape { + FIELD_TYPE, ARRAY_FIELD_TYPE, RETURN_TYPE, PARAMETER_TYPE, THROWN_TYPE, + CLASS_LITERAL, CAUGHT_TYPE, CALL_ARGUMENT_TYPE, INVOKEDYNAMIC_ARGUMENT + } + + private InputStream namingAbsent(Shape shape) { + naming('com/example/Absent', shape) + } + + /** + * A class naming the given type in one place only. + * + *

Written rather than compiled because the point is the single mention: a fixture compiled + * from source names its own package, its supertypes and whatever the compiler adds, and would + * pass or fail for reasons other than the one under test.

+ */ + private InputStream naming(String internalName, Shape shape) { + ClassWriter writer = new ClassWriter(0) + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT, + 'org/grails/aot/Written', null, 'java/lang/Object', null) + String descriptor = "L${internalName};" + + switch (shape) { + case Shape.FIELD_TYPE -> + writer.visitField(Opcodes.ACC_PRIVATE, 'held', descriptor, null, null).visitEnd() + case Shape.ARRAY_FIELD_TYPE -> + writer.visitField(Opcodes.ACC_PRIVATE, 'held', "[[${descriptor}", null, null).visitEnd() + case Shape.RETURN_TYPE -> + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT, + 'give', "()${descriptor}", null, null).visitEnd() + case Shape.PARAMETER_TYPE -> + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT, + 'take', "(${descriptor})V", null, null).visitEnd() + case Shape.THROWN_TYPE -> + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT, + 'fail', '()V', null, [internalName] as String[]).visitEnd() + default -> withBody(writer, internalName, descriptor, shape) + } + + writer.visitEnd() + new ByteArrayInputStream(writer.toByteArray()) + } + + private void withBody(ClassWriter writer, String internalName, String descriptor, Shape shape) { + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, 'body', '()V', null, null) + method.visitCode() + switch (shape) { + case Shape.CLASS_LITERAL -> { + method.visitLdcInsn(Type.getObjectType(internalName)) + method.visitInsn(Opcodes.POP) + } + case Shape.CAUGHT_TYPE -> { + Label start = new Label(), end = new Label(), handler = new Label() + method.visitTryCatchBlock(start, end, handler, internalName) + method.visitLabel(start) + method.visitLabel(end) + method.visitLabel(handler) + method.visitInsn(Opcodes.POP) + } + case Shape.CALL_ARGUMENT_TYPE -> { + // the owner is present; only what the call takes is not + method.visitInsn(Opcodes.ACONST_NULL) + method.visitInsn(Opcodes.ACONST_NULL) + method.visitMethodInsn(Opcodes.INVOKEVIRTUAL, 'java/lang/Object', + 'equals', "(${descriptor})Z", false) + method.visitInsn(Opcodes.POP) + } + case Shape.INVOKEDYNAMIC_ARGUMENT -> { + Handle bootstrap = new Handle(Opcodes.H_INVOKESTATIC, + 'java/lang/invoke/LambdaMetafactory', 'metafactory', + '(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;' + + 'Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;' + + 'Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)' + + 'Ljava/lang/invoke/CallSite;', + false) + method.visitInvokeDynamicInsn('run', '()Ljava/lang/Runnable;', bootstrap, + Type.getType('()V'), bootstrap, Type.getType("(${descriptor})V")) + method.visitInsn(Opcodes.POP) + } + default -> throw new IllegalArgumentException("${shape} has no body") + } + method.visitInsn(Opcodes.RETURN) + method.visitMaxs(3, 1) + method.visitEnd() + } + + static class Outer { + + String describe() { + new Inner().toString() + } + + static class Inner { + } + } +} diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java new file mode 100644 index 00000000000..6ff7b7fceab --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web.controllers.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the controller API a request is dispatched through. + * + *

A controller action reaches these through Groovy's dynamic dispatch, which reads a type's + * declared methods to choose an overload. An ahead-of-time image keeps only the members something + * asks for, so without these hints the methods are absent and dispatch fails at the point of use -- + * on the request that first takes that path, rather than at start-up.

+ * + *

Recording this here rather than leaving it to a tracing agent matters because the agent only + * ever sees the paths a developer happened to exercise: the method check below is reached only by + * POST, PUT and DELETE, so a walk of an application's pages never records it and the failure + * appears the first time someone submits a form.

+ * + * @since 8.0 + */ +public class ControllerRuntimeHints implements RuntimeHintsRegistrar { + + /** + * Types Groovy dispatches on while handling a request. Named as strings, and registered only + * when present, so this stays correct for an application that does not use every plugin. + */ + private static final String[] DISPATCHED_TYPES = { + "grails.artefact.Controller", + "grails.artefact.controller.support.AllowedMethodsHelper", + "grails.artefact.controller.support.RequestForwarder", + "grails.artefact.controller.support.ResponseRedirector", + "grails.artefact.controller.support.ResponseRenderer", + "grails.artefact.controller.RestResponder", + // a view asking who is logged in reaches the request's principal, and Groovy makes that + // call on the interface rather than the implementation the container supplies + "java.security.Principal" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : DISPATCHED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } +} diff --git a/grails-controllers/src/main/resources/META-INF/spring/aot.factories b/grails-controllers/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..a8cb0eb8035 --- /dev/null +++ b/grails-controllers/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.plugins.web.controllers.aot.ControllerRuntimeHints diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..8467a537142 --- /dev/null +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web.controllers.aot + +import grails.artefact.controller.support.AllowedMethodsHelper + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +/** + * Covers the controller API surviving into an ahead-of-time image. Without these hints the methods + * are stripped and dispatch fails on the request that first takes the path, which for the method + * check below means the first form submission rather than start-up. + */ +class ControllerRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new ControllerRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private boolean registered(Class type) { + def hint = hints.reflection().getTypeHint(TypeReference.of(type)) + hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the method check a form submission reaches is registered'() { + expect: 'reached only by POST, PUT and DELETE, so a walk of an application never records it' + registered(AllowedMethodsHelper) + } + + void 'the controller trait Groovy dispatches through is registered'() { + expect: + registered(grails.artefact.Controller) + } + + void 'the response and forwarding support types are registered'() { + expect: + registered(grails.artefact.controller.support.ResponseRenderer) + registered(grails.artefact.controller.support.ResponseRedirector) + registered(grails.artefact.controller.support.RequestForwarder) + } + + void 'a type absent from the classpath is skipped rather than failing the build'() { + given: + RuntimeHints empty = new RuntimeHints() + + when: 'no class loader can resolve the named types' + new ControllerRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null)) + + then: + noExceptionThrown() + } +} diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy index 434245a18a4..fe126b45870 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy @@ -18,13 +18,20 @@ */ package grails.boot +import java.lang.management.ManagementFactory + import groovy.transform.CompileStatic import groovy.transform.MapConstructor -import groovy.transform.stc.ClosureParams -import groovy.transform.stc.SimpleType +import groovy.util.logging.Slf4j import org.springframework.boot.Banner +import org.springframework.boot.ansi.Ansi8BitColor +import org.springframework.boot.ansi.AnsiColor +import org.springframework.boot.ansi.AnsiElement +import org.springframework.boot.ansi.AnsiOutput import org.springframework.boot.SpringBootVersion +import org.springframework.aot.AotDetector +import org.springframework.core.NativeDetector import org.springframework.core.SpringVersion import org.springframework.core.env.Environment import org.springframework.core.io.ClassPathResource @@ -36,6 +43,7 @@ import grails.util.BuildSettings * * @since 7.1 */ +@Slf4j @CompileStatic @MapConstructor(noArg = true) class GrailsBanner implements Banner { @@ -43,6 +51,37 @@ class GrailsBanner implements Banner { private static final int FALLBACK_BANNER_WIDTH = 0 private static final String DEFAULT_BANNER_FILE = 'grails-banner.txt' + private static final String ART_COLOR_PROPERTY = 'grails.banner.art.color' + + private static final String MARK_DISPLAY_PROPERTY = 'grails.banner.mark.display' + + private static final String MARK_TEXT_PROPERTY = 'grails.banner.mark.text' + + private static final String MARK_COLOR_PROPERTY = 'grails.banner.mark.color' + + /** Bright yellow, so the mark reads as its own thing rather than the last line of the art. */ + private static final String DEFAULT_MARK_COLOR = '226' + + /** What an application was started as, strongest first. */ + private static final String NATIVE_MARK = 'NATIVE' + private static final String CACHE_MARK = 'AOT CACHE' + private static final String AOT_MARK = 'AOT' + + /** One of the 256 colours a terminal offers: the amber the framework is shown in. */ + private static final String DEFAULT_ART_COLOR = '214' + + private static final String NO_COLOR = 'none' + + private static final String ORDER_PROPERTY = 'grails.banner.versions.order' + + private static final String EXCLUDE_PROPERTY = 'grails.banner.versions.exclude' + + private static final String INCLUDE_PROPERTY = 'grails.banner.versions.include' + + /** Everywhere an application names a version option, and so everywhere one can be misspelt. */ + private static final List VERSION_PROPERTIES = + [ORDER_PROPERTY, EXCLUDE_PROPERTY, INCLUDE_PROPERTY].asImmutable() + String bannerFile = DEFAULT_BANNER_FILE int bannerPaddingTop = 1 int bannerPaddingBottom = 1 @@ -63,15 +102,190 @@ class GrailsBanner implements Banner { bannerPaddingTop.times { out.println() } if (shouldDisplayArt(environment)) { def art = createBannerArt(environment) + // measured before colouring, so the escapes do not count towards the width the + // versions below are centred on bannerWidth = longestLineLength(art) ?: FALLBACK_BANNER_WIDTH - out.println(art) + out.println(colour(art, environment)) artPaddingBottom.times { out.println() } } - if (shouldDisplayVersions(environment)) { + printMark(environment, out, bannerWidth) + boolean displayVersions = shouldDisplayVersions(environment) + if (displayVersions) { createVersionsFormatter().format(createBannerVersions(environment), bannerWidth) .forEach { out.println(it) } } bannerPaddingBottom.times { out.println() } + if (displayVersions) { + warnAboutUnrecognisedVersions(environment) + } + } + + /** + * Says which of the version options an application configured were not recognised. + * + *

An option that names nothing is dropped, and was dropped without a word -- so a name with + * a typo in it reads as a banner that quietly ignores what it was told, and the only way to + * find out is to notice a line that is not there.

+ * + *

Said after the banner rather than while one is being built. A line logged partway through + * arrives between the mark and the versions, which is the very thing reading a version without + * initialising the library it belongs to was for.

+ */ + protected void warnAboutUnrecognisedVersions(Environment env) { + List unrecognised = unrecognisedVersionOptions(env) + if (unrecognised) { + log.warn('Ignoring unknown banner version option(s) {}; the known options are {}', + unrecognised, VersionOption.values()*.key) + } + } + + /** + * The configured version options that name none of {@link VersionOption}, in the order they + * were written. + * + *

Which list an option belongs to is not asked. An option is named by an application to say + * that it wants it or does not, and moving one between the shown-by-default set and the + * asked-for set is a decision of this class -- so an application that asked for a version that + * has since become one of the defaults said something recognisable, and is told nothing.

+ */ + protected List unrecognisedVersionOptions(Environment env) { + List known = VersionOption.values()*.key + VERSION_PROPERTIES.collectMany { String property -> + readVersionOptions(env, property).findAll { String option -> !(option in known) } + }.unique() + } + + /** + * Marks how the application was started, centred under the art and above the versions. + * + *

Only where it is worth saying: an image, a JVM given a cache to read, or one running bean + * definitions that were generated. An ordinary start says nothing.

+ * + *

Written plainly, and deliberately: a banner is printed on the thread that is starting the + * application, which cannot get on until this returns. Anything drawn over time here is time the + * application is not starting, and an image that starts in two thirds of a second should not + * spend a third of it on its own announcement.

+ */ + protected void printMark(Environment environment, PrintStream out, int bannerWidth) { + if (!environment.getProperty(MARK_DISPLAY_PROPERTY, Boolean, true)) { + return + } + String mark = resolveMark(environment) + if (!mark) { + return + } + String label = spaced(mark) + String indent = ' ' * Math.max(0, (bannerWidth - label.length()).intdiv(2)) + AnsiElement colour = resolveMarkColour(environment) + out.println(indent + (colour == null ? label : AnsiOutput.toString(colour, label, AnsiColor.DEFAULT))) + } + + /** + * What to say, strongest first, or nothing where an application started the ordinary way. + * + *

An image has already done all of it. A cache means the JDK was handed what a previous run + * worked out. Generated bean definitions are the smaller half of the same idea, and worth + * saying on their own because an application can be run either with them or without.

+ */ + protected String resolveMark(Environment environment) { + String configured = environment.getProperty(MARK_TEXT_PROPERTY, String) + if (configured != null) { + return configured.trim() ?: null + } + if (isNativeImage()) { + return NATIVE_MARK + } + if (readsAotCache()) { + return CACHE_MARK + } + AotDetector.useGeneratedArtifacts() ? AOT_MARK : null + } + + /** + * Whether the JDK was given a cache to read. + * + *

Asked of the arguments the JVM was started with rather than of the cache: a JVM handed one + * it cannot use declines it and starts as it would have anyway, and this is the banner rather + * than a diagnostic.

+ */ + protected boolean readsAotCache() { + ManagementFactory.runtimeMXBean.inputArguments.any { String argument -> + argument.startsWith('-XX:AOTCache=') || argument.startsWith('-XX:AOTMode=') + } + } + + /** + * Spaced so it reads as a mark rather than a word. + * + *

Spaced as written rather than shouted: the marks this draws are already upper case, and + * an application that says what it wants the mark to be gets what it said. A name whose case + * is part of it -- CRaC -- keeps it.

+ */ + private static String spaced(String word) { + word.toCharArray().join(' ') + } + + /** Whether this is running as an image. A seam, so the mark can be covered without being one. */ + protected boolean isNativeImage() { + NativeDetector.inNativeImage() + } + + /** + * The art in the configured colour, or as it stands where none is wanted. + * + *

{@link AnsiOutput} writes the escapes only where colour has been enabled, so this is the + * same string on a terminal that cannot colour, in a redirected log, and wherever an application + * has turned colour off.

+ */ + protected String colour(String art, Environment environment) { + AnsiElement colour = resolveArtColour(environment) + colour == null ? art : AnsiOutput.toString(colour, art, AnsiColor.DEFAULT) + } + + /** + * The colour to show the art in, read from {@code grails.banner.art.color}. + * + *

Takes a number for one of the 256 colours a terminal offers, a name for one of the eight + * it has always had ({@code red}, {@code bright_blue}), or {@code none} to leave the art as it + * stands. A value that is neither falls back to the default rather than failing to start over + * the colour of a banner.

+ */ + protected AnsiElement resolveArtColour(Environment environment) { + resolveColour(environment, ART_COLOR_PROPERTY, DEFAULT_ART_COLOR) + } + + /** + * The colour to show the mark in, read from {@code grails.banner.mark.color}. + * + *

Its own colour rather than the art's, and brighter, so that what an application was started + * as is not read as the last line of the drawing above it. Takes the same values as + * {@code grails.banner.art.color}.

+ */ + protected AnsiElement resolveMarkColour(Environment environment) { + resolveColour(environment, MARK_COLOR_PROPERTY, DEFAULT_MARK_COLOR) + } + + private AnsiElement resolveColour(Environment environment, String property, String fallback) { + String configured = environment.getProperty(property, String, fallback) + if (!configured || configured.equalsIgnoreCase(NO_COLOR)) { + return null + } + if (configured.isInteger()) { + int code = configured.toInteger() + // A terminal offers 256 of them, and anything else writes an escape it does not + // understand -- which shows as the escape itself, printed into the banner. + return code in 0..255 ? Ansi8BitColor.foreground(code) : defaultColour(fallback) + } + try { + return AnsiColor.valueOf(configured.toUpperCase()) + } + catch (IllegalArgumentException ignored) { + return defaultColour(fallback) + } + } + + private static AnsiElement defaultColour(String fallback) { + Ansi8BitColor.foreground(fallback.toInteger()) } /** @@ -104,12 +318,11 @@ class GrailsBanner implements Banner { * @param env the current env * @return a map of version labels to version values */ - @SuppressWarnings('GrMethodMayBeStatic') protected Map createBannerVersions(Environment env) { def defaultIncluded = (DefaultVersionOption.values()).collect { it.key } - def sortOrder = findConfiguredVersions(env, 'grails.banner.versions.order') { it in VersionOption.values()*.key } - def configExcluded = findConfiguredVersions(env, 'grails.banner.versions.exclude') { it in DefaultVersionOption.values()*.key } - def configIncluded = findConfiguredVersions(env, 'grails.banner.versions.include') { it in OptionalVersionOption.values()*.key } + def sortOrder = findConfiguredVersions(env, ORDER_PROPERTY) + def configExcluded = findConfiguredVersions(env, EXCLUDE_PROPERTY) + def configIncluded = findConfiguredVersions(env, INCLUDE_PROPERTY) def includedVersions = defaultIncluded .tap { removeAll(configExcluded) } .tap { addAll(configIncluded) } @@ -129,7 +342,10 @@ class GrailsBanner implements Banner { } } } - includedVersions.collectEntries { key -> + // A library that is not there, or that records no version, is left out rather than shown + // as unknown. That is what lets a version be on by default: an application without Spring + // Security says nothing about it, instead of saying it does not know. + Map versions = includedVersions.collectEntries { key -> switch (VersionOption.fromString(key)) { case VersionOption.APP: [(env.getProperty('info.app.name') ?: 'app'): env.getProperty('info.app.version') ?: 'unknown'] @@ -152,52 +368,136 @@ class GrailsBanner implements Banner { case VersionOption.SPRING_SECURITY: ['Spring Security': findVersion('org.springframework.security.core.SpringSecurityCoreVersion')] break + case VersionOption.CONTAINER: + findContainerVersion() + break case VersionOption.TOMCAT: - ['Tomcat': findVersion('org.apache.catalina.util.ServerInfo')] + ['Tomcat': findTomcatVersion()] break case VersionOption.JETTY: - ['Jetty': findVersion('org.eclipse.jetty.util.Jetty')] + ['Jetty': findJettyVersion()] break case VersionOption.UNDERTOW: - ['Undertow': findVersion('io.undertow.Undertow')] + ['Undertow': findUndertowVersion()] break default: null } } as Map + versions.findAll { String label, String version -> version != null } + } + + /** + * The servlet container the application is running on, and the version it records. + * + *

An application runs on one container: choosing another is done by excluding the starter + * for this one, so two are not on the classpath together. They are therefore tried in the order + * they are commonly used and the first one found is the answer -- an application on Tomcat + * never goes looking for Jetty.

+ * + *

On a container that records no version, or on none of these, this is empty and the banner + * leaves the line out rather than saying it does not know.

+ */ + protected Map findContainerVersion() { + String tomcat = findTomcatVersion() + if (tomcat != null) { + return ['Tomcat': tomcat] + } + String jetty = findJettyVersion() + if (jetty != null) { + return ['Jetty': jetty] + } + String undertow = findUndertowVersion() + if (undertow != null) { + return ['Undertow': undertow] + } + return [:] } /** - * Finds the implementation version of the specified class. + * Tomcat's version, read from the resource it ships rather than only from its manifest. * - * @param className the fully qualified class name - * @return the implementation version, or 'unknown' if not found + *

A resource survives being repackaged into an executable jar or built into an image, where + * the manifest's attributes are no longer attached to the package -- which is why the manifest + * route reads as nothing in exactly the two places a version is most worth having.

*/ - private static String findVersion(String className) { + protected String findTomcatVersion() { + findVersionInResource('org/apache/catalina/util/ServerInfo.properties', 'server.number') + ?: findVersion('org.apache.catalina.util.ServerInfo') + } + + protected String findJettyVersion() { + findVersion('org.eclipse.jetty.util.Jetty') + } + + protected String findUndertowVersion() { + findVersion('io.undertow.Undertow') + } + + /** + * A version a library records in a resource it ships, read without loading any of its classes. + * + *

The manifest route only works while a jar is a plain entry on the classpath. Repackaged + * into an executable jar its attributes are no longer attached to the package, and an image has + * no jars at all -- which is why a container version read that way reads as nothing in exactly + * the two places it is most worth having. A resource is still a resource in both.

+ * + * @param resource the classpath location of the resource to read + * @param key the property within it that carries the version + * @return the version, or {@code null} where the resource or the property is absent + */ + protected static String findVersionInResource(String resource, String key) { + InputStream stream = GrailsBanner.classLoader.getResourceAsStream(resource) + if (stream == null) { + return null + } try { - def pkg = Class.forName(className).package - return pkg?.implementationVersion ?: 'unknown' + Properties properties = new Properties() + stream.withCloseable { properties.load(it) } + return properties.getProperty(key) + } + catch (IOException ignored) { + return null + } + } + + /** + * The version a library records in the manifest of the jar it ships in. + * + *

Loaded without being initialised. A version is read about a library rather than + * from it, and running a static initialiser to find one lets the library do whatever it + * does on the way -- Spring Security logs a line of its own from there, which arrived in the + * middle of the banner, between the mark and the very versions it was being read for. The + * manifest is attached to the package when the class is loaded, and loading is all this + * needs.

+ * + * @param className the fully qualified name of a class the library ships + * @return the version, or {@code null} where the class is absent or records none + */ + protected static String findVersion(String className) { + try { + Package pkg = Class.forName(className, false, GrailsBanner.classLoader).package + return pkg?.implementationVersion } catch (ClassNotFoundException ignore) { - return 'unknown' + return null } } /** - * Finds the configured versions from the environment. + * The version options configured under the given property that name something. * * @param env the current environment * @param propertyName the property name to look for - * @param filter the filter closure - * @return a list of configured versions + * @return the options that name a {@link VersionOption}, in the order they were written */ - private static List findConfiguredVersions( - Environment env, - String propertyName, - @ClosureParams( - value = SimpleType, - options = ['java.lang.String'] - ) Closure filter) { - env.getProperty(propertyName, List, [] as List).findAll(filter) + private static List findConfiguredVersions(Environment env, String propertyName) { + List known = VersionOption.values()*.key + readVersionOptions(env, propertyName).findAll { String option -> option in known } + } + + /** What an application wrote under the given property, or nothing where it wrote none. */ + private static List readVersionOptions(Environment env, String propertyName) { + env.getProperty(propertyName, List, [] as List) } /** @@ -323,6 +623,7 @@ class GrailsBanner implements Banner { SPRING_BOOT, SPRING, SPRING_SECURITY, + CONTAINER, TOMCAT, JETTY, UNDERTOW @@ -352,7 +653,9 @@ class GrailsBanner implements Banner { GRAILS, GROOVY, SPRING_BOOT, - SPRING + SPRING, + SPRING_SECURITY, + CONTAINER final String key @@ -363,10 +666,13 @@ class GrailsBanner implements Banner { /** * Enumeration of optional version options. + * + *

The container being run is shown by default under {@code container}, which is the one an + * application is on. These name a particular container instead, for an application that wants + * to be told about one whether or not it is the one serving.

*/ @CompileStatic enum OptionalVersionOption { - SPRING_SECURITY, TOMCAT, JETTY, UNDERTOW diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy index 297ae6046fd..c37840bfb5a 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy @@ -29,6 +29,12 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory import org.springframework.beans.factory.config.ConfigurableListableBeanFactory import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor +import org.springframework.context.annotation.AnnotationConfigUtils +import org.springframework.aot.AotDetector +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.context.annotation.CommonAnnotationBeanPostProcessor +import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor import org.springframework.beans.factory.support.BeanRegistryAdapter import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContextAware @@ -238,6 +244,8 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces @Override void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + registerAnnotationConfigProcessorsForGeneratedArtifacts(registry) + def springConfig = new DefaultRuntimeSpringConfiguration() def application = grailsApplication Holders.setGrailsApplication(application) @@ -248,7 +256,12 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces pluginManager.doRuntimeConfiguration(springConfig) } - if (loadExternalBeans) { + // Running on generated artifacts these beans are already registered: the application's own + // definitions were read while the artifacts were being generated and what they declared was + // written out as code. Reading them again would register them a second time, and reading + // the Groovy one means compiling a script -- which an image cannot do at all, so an + // application that has a spring/resources.groovy did not start. + if (loadExternalBeans && !AotDetector.useGeneratedArtifacts()) { // now allow overriding via application def context = application.mainContext @@ -322,6 +335,46 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces } } + /** + * Restores the processors that read the injection annotations, when running on artifacts + * generated ahead of time. + * + *

Generating those artifacts normally makes these unnecessary: the generator reads the + * annotations itself and writes the field and method access into the code it emits, which is why + * a controller or a tag library arrives fully injected without them. It can only do that for a + * bean whose implementation it can see, and a bean contributed as an interface built by a + * supplier hides it -- the link generator is declared as {@code LinkGenerator} and built by a + * closure, so the {@code @Autowired} field on the implementation is generated for by nobody. + * Nothing fails at start-up; the first page that follows a link does.

+ * + *

Only the two that inject are restored. Registering the whole set would bring back the + * processor that reads configuration classes, and reading them again in a context whose + * configuration has already been generated makes a second definition for beans the generated + * code has already contributed.

+ * + *

They are registered under the names Spring uses itself, so a context that already has them + * keeps what it has, and a context running without generated artifacts is untouched.

+ */ + protected static void registerAnnotationConfigProcessorsForGeneratedArtifacts(BeanDefinitionRegistry registry) { + if (!AotDetector.useGeneratedArtifacts()) { + return + } + registerInfrastructureBean(registry, AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME, + AutowiredAnnotationBeanPostProcessor) + registerInfrastructureBean(registry, AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME, + CommonAnnotationBeanPostProcessor) + } + + private static void registerInfrastructureBean(BeanDefinitionRegistry registry, String beanName, + Class beanClass) { + if (registry.containsBeanDefinition(beanName)) { + return + } + RootBeanDefinition definition = new RootBeanDefinition(beanClass) + definition.role = BeanDefinition.ROLE_INFRASTRUCTURE + registry.registerBeanDefinition(beanName, definition) + } + @Override void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory() diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy index 6e800e43982..3ce991577b5 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy @@ -26,6 +26,8 @@ import org.springframework.aop.config.AopConfigUtils import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContextAware import org.springframework.context.annotation.Bean +import org.springframework.aot.AotDetector +import org.springframework.beans.factory.config.SingletonBeanRegistry import org.springframework.core.io.support.PathMatchingResourcePatternResolver import grails.boot.config.tools.ClassPathScanner @@ -34,6 +36,7 @@ import grails.core.GrailsApplication import grails.core.GrailsApplicationClass import org.apache.grails.core.plugins.PluginDiscovery import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator +import org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator /** @@ -78,6 +81,11 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont * @return The classes that constitute the Grails application */ Collection classes() { + Collection written = artefactsWrittenDownAheadOfTime() + if (written != null) { + return written + } + if (limitScanningToApplication()) { return ApplicationArtefactScanner.scanApplicationClasses(getClass(), packageNames()) } @@ -88,6 +96,29 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont return classes } + /** + * The artefacts written down while the application's code was generated, or {@code null} where + * nothing was written down and they are to be found the usual ways. + * + *

Both usual ways need something an image does not have: one walks the classpath, the other + * reads a list the compile-time transform builds as it goes, which is empty in anything the + * transform did not itself compile. So an image found no artefacts at all, and an application + * could only start by naming its own -- a list to keep in step with itself forever after.

+ * + *

They were found while the code was generated, on an ordinary JVM where both ways work, and + * left here.

+ */ + protected Collection artefactsWrittenDownAheadOfTime() { + if (applicationContext == null || !AotDetector.useGeneratedArtifacts()) { + return null + } + Object written = applicationContext.autowireCapableBeanFactory instanceof SingletonBeanRegistry + ? ((SingletonBeanRegistry) applicationContext.autowireCapableBeanFactory) + .getSingleton(ArtefactClassesBeanFactoryInitializationAotProcessor.BEAN_NAME) + : null + written instanceof Class[] ? Arrays.asList((Class[]) written) : null + } + /** * Whether classpath scanning should be limited to the application and not dependent JAR files. Users can override this method to enable more broad scanning * at the cost of startup time. diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java index 2650bb300be..ee823f1a5e7 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java @@ -22,6 +22,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +33,7 @@ import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.MutablePropertySources; import org.springframework.core.env.PropertySource; import org.springframework.core.io.Resource; @@ -74,8 +76,15 @@ public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 15; } + /** Where Spring Boot reads whether to colour its output. */ + private static final String ANSI_ENABLED = "spring.output.ansi.enabled"; + + /** Set in an image, and in nothing else. */ + private static final String IMAGE_CODE = "org.graalvm.nativeimage.imagecode"; + @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + colourTheOutputOfAnImageThatHasATerminal(environment); try { PluginDiscovery pluginDiscovery = bootstrapContext.get(PluginDiscovery.class); if (pluginDiscovery == null) { @@ -92,6 +101,43 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp } } + /** + * Colours the output of an image running at a terminal, which it otherwise cannot tell it has. + * + *

Spring Boot decides by asking for the console, and an image answers that it has none even + * when it is being watched at a terminal. So the same application whose start-up is coloured + * under {@code bootRun} arrives plain once it is built, for a reason that has nothing to do with + * the terminal it is running at.

+ * + *

What the environment names as the terminal is read instead, which an image does carry. + * That does not distinguish output being watched from output being redirected -- nothing in an + * image does, which is the whole difficulty -- so a shell that redirects to a file still gets + * the escapes. It does distinguish a shell from the places that name no terminal at all: a + * build, a container, a service manager, where the output is only ever read later and stays + * plain. An application that has said either way is left alone.

+ */ + private void colourTheOutputOfAnImageThatHasATerminal(ConfigurableEnvironment environment) { + if (!isImage() || environment.containsProperty(ANSI_ENABLED)) { + return; + } + String terminal = terminal(); + if (terminal == null || terminal.isEmpty() || "dumb".equals(terminal)) { + return; + } + environment.getPropertySources().addLast(new MapPropertySource("grails.ansi.output", + Map.of(ANSI_ENABLED, "always"))); + } + + /** Whether this is running as an image, which only a run can answer. */ + protected boolean isImage() { + return System.getProperty(IMAGE_CODE) != null; + } + + /** What the environment names as the terminal, if it names one. */ + protected String terminal() { + return System.getenv("TERM"); + } + /** * Loads plugin configuration files ({@code plugin.yml} or {@code plugin.groovy}) * in the topologically sorted order and adds them to the environment's property sources. diff --git a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy index c65796ecde2..687382b77d9 100644 --- a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy +++ b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy @@ -21,6 +21,7 @@ package org.grails.plugins import groovy.transform.CompileStatic import org.springframework.aop.config.AopConfigUtils +import org.springframework.aot.AotDetector import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.BeanRegistry import org.springframework.beans.factory.config.CustomEditorConfigurer @@ -29,6 +30,9 @@ import org.springframework.beans.factory.xml.XmlBeanDefinitionReader import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.AutoConfigureOrder import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration +import org.springframework.context.ApplicationContext +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.AnnotationConfigUtils import org.springframework.context.annotation.ConfigurationClassPostProcessor import org.springframework.context.support.GenericApplicationContext import org.springframework.context.support.PropertySourcesPlaceholderConfigurer @@ -118,6 +122,31 @@ class CoreGrailsPlugin extends Plugin { } } + /** + * Whether the context already has a processor that parses configuration classes. + * + *

Spring registers one under a well-known name as part of setting up annotation + * configuration, which is every application context that reads annotations. A context assembled + * without that step -- a test slice registering this plugin's beans on a bare registry -- has + * none, and is what the plugin's own processor is for.

+ */ + private static boolean hasConfigurationClassPostProcessor(GrailsApplication application) { + hasBeanDefinition(application, AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME) + } + + /** + * Whether the context already has a definition under this name. + * + *

These registrars run after the {@code doWithSpring} drain, so a plugin that has already + * declared a bean under one of these names has declared the one that should stand: registering + * over it replaces something chosen for the application with the general case.

+ */ + private static boolean hasBeanDefinition(GrailsApplication application, String beanName) { + ApplicationContext context = application.mainContext + context instanceof ConfigurableApplicationContext && + ((ConfigurableApplicationContext) context).beanFactory.containsBeanDefinition(beanName) + } + /** * Scans the packages named by {@code grails.spring.bean.packages}. Contributed here rather * than through {@code doWithSpring}'s {@code grailsContext:component-scan} element, which @@ -129,8 +158,27 @@ class CoreGrailsPlugin extends Plugin { GrailsApplication application = grailsApplication Config config = application.config - // enable post-processing of @Configuration beans defined by plugins - registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor) + // enable post-processing of @Configuration beans defined by plugins. An AOT-optimized + // context has no ConfigurationClassPostProcessor of its own: the configuration classes + // were parsed at build time and their beans are already in the generated initializer, + // so registering one here would parse them a second time. A context that annotation + // configuration has already been set up on has one of its own, which sees the plugin + // definitions because they are registered ahead of it; a second processor over the same + // registry parses everything a second time, and while code is being generated the two of + // them write out the same import-aware post-processor twice, so one registration + // replaces the other on every start. + // + // "Registered ahead of it" is what makes standing down safe, and it is a property of how + // the application started rather than of this registry. GrailsEarlyPluginRegistrationPostProcessor + // is added with addBeanFactoryPostProcessor and so runs before Spring's own processor, + // and it runs whenever PluginDiscovery was promoted to the bean factory -- which + // GrailsBootstrapRegistryInitializer does, from spring.factories, for every + // SpringApplication. A context assembled without SpringApplication would have Spring's + // processor already finished by the time these definitions arrive, and would need this + // one; it would also not be a Grails application started any supported way. + if (!AotDetector.useGeneratedArtifacts() && !hasConfigurationClassPostProcessor(application)) { + registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor) + } registry.registerBean('grailsBeanOverrideConfigurer', MapBasedSmartPropertyOverrideConfigurer) { it.supplier { @@ -189,7 +237,12 @@ class CoreGrailsPlugin extends Plugin { } } - registry.registerBean('proxyHandler', DefaultProxyHandler) + // The GORM implementations register a proxy handler that knows how to unwrap their own + // proxies; this is the one for an application that has none. Registering it over theirs + // left a Hibernate application unwrapping Hibernate proxies with the general case. + if (!hasBeanDefinition(application, 'proxyHandler')) { + registry.registerBean('proxyHandler', DefaultProxyHandler) + } // an abstract parent definition, which registerBean cannot express since it always // takes a class; third-party plugins inherit their search locations from it diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java index 7149a707ece..b53bec5af07 100644 --- a/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java +++ b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java @@ -26,7 +26,9 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.context.aot.AbstractAotProcessor; import org.springframework.core.PriorityOrdered; +import org.springframework.core.SpringProperties; /** * Registers {@code abstractGrailsResourceLocator}, the abstract parent definition that @@ -61,10 +63,25 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t } GenericBeanDefinition definition = new GenericBeanDefinition(); definition.setAbstract(true); - definition.getPropertyValues().add("searchLocations", this.searchLocations); + definition.getPropertyValues().add("searchLocations", searchLocationsToInherit()); registry.registerBeanDefinition(BEAN_NAME, definition); } + /** + * The locations to be inherited, which while code is being generated are none. + * + *

These are directories on the machine this runs on, and a child definition merges them in. + * Generating code for that child writes them into it, so an application would carry the + * directory it was built in and look for its resources there -- a path that says where it was + * built and, wherever it then runs, is not where its resources are. A generated application + * reads them from its own contents instead, which is what is left when there is nowhere named + * to look.

+ */ + private List searchLocationsToInherit() { + return SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING) ? + List.of() : this.searchLocations; + } + /** * Ordered first, because a child definition naming this one as its parent cannot be merged * until it exists, and merging happens as soon as anything resolves beans by type. diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java new file mode 100644 index 00000000000..58808c10676 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter; +import org.springframework.beans.factory.support.RegisteredBean; + +/** + * Keeps abstract bean definitions out of ahead-of-time processing. + * + *

An abstract definition is a template: it carries property values for children to inherit + * and is never instantiated. Spring's bean-definition code generator has no representation for + * that — it emits neither the abstract flag nor a bean class, so the definition is regenerated + * as a concrete bean of type {@code Object} carrying the template's properties, which fails as + * soon as the context applies them.

+ * + *

Children are unaffected: ahead-of-time processing generates them from their merged + * definition, so inherited values are already folded in and no parent is needed at runtime. + * A definition contributed dynamically still finds its parent, because the post-processor that + * registers the template runs during refresh in an ahead-of-time context as it does in any other.

+ * + * @since 8.0 + * @see org.grails.spring.beans.AbstractResourceLocatorPostProcessor + */ +public class AbstractBeanDefinitionExcludeFilter implements BeanRegistrationExcludeFilter { + + @Override + public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) { + return registeredBean.getMergedBeanDefinition().isAbstract(); + } + +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java new file mode 100644 index 00000000000..1415d88e56b --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import javax.lang.model.element.Modifier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GeneratedMethod; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.javapoet.CodeBlock; + +import grails.core.GrailsApplication; +import grails.plugins.GrailsPlugin; +import grails.plugins.GrailsPluginManager; + +/** + * Writes down the artefacts an application is made of, while they can still be found. + * + *

They are found two ways, and an image has neither. The classpath is scanned for the classes + * under the application's own packages, which needs a classpath to walk; and a list the compile-time + * transform builds as it goes is consulted, which lives in a static field of the transform and is + * therefore empty in anything the transform did not itself compile.

+ * + *

So an image found no controllers, no domain classes and no URL mappings, and the application + * failed to start on the first bean that wanted one. The only way an application could start was to + * override {@code classes()} and list its own artefacts by hand, which is a thing to keep in step + * with the application forever after.

+ * + *

Generation runs on an ordinary JVM with the full classpath, where both ways work. What they + * found is written into the generated code here, and read back by {@link + * grails.boot.config.GrailsAutoConfiguration#classes()}.

+ * + * @since 8.0 + */ +public class ArtefactClassesBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor { + + /** + * Where the classes are left for {@code classes()} to find. A singleton rather than a bean + * definition, because it is read while the definitions are still being contributed. + */ + public static final String BEAN_NAME = "grailsArtefactClasses"; + + @Override + @Nullable + public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) { + List> artefacts = artefactsOf(beanFactory); + if (artefacts.isEmpty()) { + return null; + } + return (generationContext, beanFactoryInitializationCode) -> + contribute(artefacts, beanFactoryInitializationCode); + } + + /** + * The artefacts the application was found to be made of, or none if this is not a Grails + * application context -- a plain Spring one being generated has no {@code grailsApplication}. + */ + private List> artefactsOf(ConfigurableListableBeanFactory beanFactory) { + List> artefacts = new ArrayList<>(); + Object application = beanFactory.getSingleton(GrailsApplication.APPLICATION_ID); + if (!(application instanceof GrailsApplication grailsApplication)) { + return artefacts; + } + Class[] allClasses = grailsApplication.getAllClasses(); + if (allClasses == null) { + return artefacts; + } + Set> providedByPlugins = providedByPlugins(beanFactory); + for (Class artefact : allClasses) { + if (artefact != null && isNamed(artefact) && !providedByPlugins.contains(artefact)) { + artefacts.add(artefact); + } + } + return artefacts; + } + + /** + * The artefacts the plugins bring with them, which are not the application's to declare. + * + *

They are registered from the plugins on every start, so writing them down here would have + * the application claim them as its own -- and a codec or a tag library registered twice, once + * as a plugin's and once as the application's, is not the same as registered once.

+ */ + private Set> providedByPlugins(ConfigurableListableBeanFactory beanFactory) { + Set> provided = new LinkedHashSet<>(); + Object manager = beanFactory.getSingleton(GrailsPluginManager.BEAN_NAME); + if (!(manager instanceof GrailsPluginManager pluginManager)) { + return provided; + } + for (GrailsPlugin plugin : pluginManager.getAllPlugins()) { + Class[] artefacts = plugin.getProvidedArtefacts(); + if (artefacts != null) { + provided.addAll(Arrays.asList(artefacts)); + } + } + return provided; + } + + /** + * Whether the class can be written down and read back. One generated as the application ran -- + * a proxy, or a script compiled from a string -- has a name that resolves to nothing next time. + */ + private boolean isNamed(Class artefact) { + return !artefact.isSynthetic() && !artefact.isAnonymousClass() && + !artefact.isLocalClass() && artefact.getCanonicalName() != null; + } + + private void contribute(List> artefacts, BeanFactoryInitializationCode beanFactoryInitializationCode) { + GeneratedMethod method = beanFactoryInitializationCode.getMethods() + .add("registerArtefactClasses", builder -> { + builder.addJavadoc("Register the artefacts this application is made of."); + builder.addModifiers(Modifier.PUBLIC, Modifier.STATIC); + builder.addParameter(ConfigurableListableBeanFactory.class, + BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE); + builder.addStatement("$L.registerSingleton($S, $L)", + BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE, BEAN_NAME, arrayOf(artefacts)); + }); + beanFactoryInitializationCode.addInitializer(method.toMethodReference()); + } + + /** The classes as an array literal, in the order they were found, so a run reads as a build did. */ + private CodeBlock arrayOf(List> artefacts) { + CodeBlock.Builder array = CodeBlock.builder().add("new $T[] {", Class.class); + for (int i = 0; i < artefacts.size(); i++) { + array.add(i == 0 ? "$T.class" : ", $T.class", artefacts.get(i)); + } + return array.add("}").build(); + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java new file mode 100644 index 00000000000..82a52b4b084 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessor.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotContribution; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.aot.BeanRegistrationCode; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.javapoet.CodeBlock; + +/** + * Carries a bean's autowire mode into the code generated for it ahead of time. + * + *

Grails registers much of what it contributes as autowired by name: a tag library, a controller + * or an interceptor takes its collaborators from beans of the same name, without annotating them. + * The generator writes out most of a definition but not this, so a bean rebuilt from generated code + * arrives with those collaborators unset -- a tag library holding null where it expects a message + * source, and a link generator holding null where it expects the URL mappings. Nothing fails at + * start-up; the first page that reaches one of them does.

+ * + *

The annotated injection is unaffected either way, because the generator resolves that itself + * and writes the field and method access into the instance supplier. This only restores what + * convention supplies.

+ * + * @since 8.0 + */ +public class AutowireModeBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor { + + @Override + @Nullable + public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { + int autowireMode = autowireModeOf(registeredBean); + if (autowireMode == AbstractBeanDefinition.AUTOWIRE_NO) { + return null; + } + return BeanRegistrationAotContribution.withCustomCodeFragments( + codeFragments -> new AutowireModeCodeFragments(codeFragments, autowireMode)); + } + + private int autowireModeOf(RegisteredBean registeredBean) { + RootBeanDefinition definition = registeredBean.getMergedBeanDefinition(); + return definition.getAutowireMode(); + } + + /** Appends the assignment the generator leaves out to the properties it does write. */ + private static final class AutowireModeCodeFragments extends BeanRegistrationCodeFragmentsDecorator { + + private final int autowireMode; + + private AutowireModeCodeFragments(BeanRegistrationCodeFragments delegate, int autowireMode) { + super(delegate); + this.autowireMode = autowireMode; + } + + @Override + public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext generationContext, + BeanRegistrationCode beanRegistrationCode, RootBeanDefinition beanDefinition, + Predicate attributeFilter) { + CodeBlock properties = super.generateSetBeanDefinitionPropertiesCode(generationContext, + beanRegistrationCode, beanDefinition, attributeFilter); + return CodeBlock.builder() + .add(properties) + .addStatement("$L.setAutowireMode($L)", BEAN_DEFINITION_VARIABLE, this.autowireMode) + .build(); + } + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java new file mode 100644 index 00000000000..361358200f2 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.beans.factory.BeanRegistrar; +import org.springframework.beans.factory.BeanRegistry; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; + +/** + * Registers the registry a plugin declares its beans against. + * + *

A plugin declares them in a closure, and Groovy resolves every call in a closure where the call + * is written rather than when the closure is compiled. So each call on the registry is made + * reflectively, and an image keeps a method for that only when something has asked it to.

+ * + *

Nothing does: the registry belongs to Spring, an application never names it, and the closures + * that call it are registered as closures rather than for what they call. The image then refuses the + * first call and the context does not start, reporting a method of an interface that appears nowhere + * in the application -- a plugin declaring a bean with a specification, which most of them do.

+ * + *

The same is true of the registry the older bean DSL is handed: a plugin that still declares + * its beans that way asks whether one is already registered, and that call is made the same + * reflective way. It failed later than the others, once a datastore came to be configured.

+ * + *

The types are named here rather than scanned for, because they belong to Spring and are few.

+ * + * @since 8.0 + */ +public class BeanRegistrarRuntimeHints implements RuntimeHintsRegistrar { + + /** The registry, what hands a plugin to it, and the types its calls pass through. */ + private static final Class[] TYPES = { + BeanRegistrar.class, + BeanRegistry.class, + BeanRegistry.Spec.class, + BeanRegistry.SupplierContext.class, + // The registry the older bean DSL is handed, which the plugins that still use it call the + // same way: asking whether a bean is already there, registering one, naming an alias. + BeanDefinitionRegistry.class, + ListableBeanFactory.class, + ConfigurableListableBeanFactory.class + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (Class type : TYPES) { + hints.reflection().registerType(type, MemberCategory.INVOKE_DECLARED_METHODS); + } + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java new file mode 100644 index 00000000000..67b5e522947 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertyResolver; +import org.springframework.core.io.ResourceLoader; + +import grails.config.Config; +import grails.config.ConfigMap; +import grails.core.GrailsApplication; +import grails.core.GrailsClass; +import grails.plugins.GrailsPlugin; +import grails.plugins.GrailsPluginManager; + +/** + * Registers the interfaces a plugin reaches through dynamically: the framework's own, and the + * Spring ones it is handed. + * + *

A plugin descriptor is Groovy, and much of it is written without static compilation -- so + * reading a configuration value, asking the application for its artefacts, or asking the plugin + * manager about a plugin are all calls resolved where they are written rather than when the + * descriptor is compiled. Each is therefore made reflectively.

+ * + *

An image keeps a method for that only when something has asked it to, and an application never + * names these itself: they are the framework's, called from the framework's own descriptors. The + * image refuses the call where it is made, and what it reports is a method of an interface that + * appears nowhere in the application -- the last of them stopping a context from starting on + * {@code ConfigMap.getProperty}, which is how nearly every plugin reads its settings.

+ * + *

The Spring interfaces are here for the same reason rather than a different one: a descriptor is + * given the environment and the context and calls them the same dynamic way, and an application does + * not name them either. Reading a setting from the environment stopped a Hibernate application from + * starting in exactly the way reading one from the configuration stopped every application.

+ * + *

Only the interfaces are named. What implements them is reached through them, and registering + * the implementations would be registering most of the framework.

+ * + * @since 8.0 + */ +public class GrailsApiRuntimeHints implements RuntimeHintsRegistrar { + + /** What a descriptor written without static compilation calls on the framework. */ + private static final Class[] TYPES = { + Config.class, + ConfigMap.class, + GrailsApplication.class, + GrailsClass.class, + GrailsPlugin.class, + GrailsPluginManager.class, + // What a descriptor is handed by Spring and calls the same way + Environment.class, + ConfigurableEnvironment.class, + PropertyResolver.class, + ApplicationContext.class, + ConfigurableApplicationContext.class, + ResourceLoader.class + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (Class type : TYPES) { + hints.reflection().registerType(type, MemberCategory.INVOKE_DECLARED_METHODS); + } + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java new file mode 100644 index 00000000000..c0167de49e4 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHints.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.TypeReference; + +import grails.boot.GrailsBanner; + +/** + * Keeps the banner's option enums usable in an image, including the ones an application has not + * turned on. + * + *

A Groovy enum builds its constants in a static initialiser that reaches its own constructor + * through the metaclass rather than calling it. An image keeps a constructor only when something + * has asked it to, so the enum initialises to + * {@code Could not find matching constructor for: ...OptionalVersionOption(String, Integer)} -- + * thrown while printing the banner, before anything the application wrote has run.

+ * + *

Which enums are reached depends on configuration: the optional ones are touched only where + * {@code grails.banner.versions.include} names something. So an image traced with nothing included + * carries no record of them, and an application that later asks for the Tomcat version gets a + * start-up failure out of a banner setting. They are registered here rather than left to a trace + * for that reason -- all three are small, and the alternative is metadata that depends on which + * options happened to be on the day it was collected.

+ * + * @since 8.0 + */ +public class GrailsBannerRuntimeHints implements RuntimeHintsRegistrar { + + private static final Class[] OPTION_TYPES = { + GrailsBanner.VersionOption.class, + GrailsBanner.DefaultVersionOption.class, + GrailsBanner.OptionalVersionOption.class + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (Class type : OPTION_TYPES) { + hints.reflection().registerType(type, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.ACCESS_DECLARED_FIELDS); + // The name Groovy looks up before deciding an enum has no metaclass of its own. It is + // not a class anyone writes, so it is named rather than referenced. + hints.reflection().registerType( + TypeReference.of("groovy.runtime.metaclass." + type.getName() + "MetaClass")); + } + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java new file mode 100644 index 00000000000..4928450914d --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import java.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.util.ClassUtils; + +import org.grails.aot.RegistrableTypes; + +/** + * Registers the framework closures Groovy dispatches through. + * + *

Calling a closure goes through {@code doCall}, and Groovy reads its parameter types + * reflectively to choose an overload. In an ahead-of-time image those types are stripped unless + * something asks for them, and the call fails where the closure is used rather than at start-up. + * The framework ships thousands of closures across its plugins, so naming them individually would + * be neither complete nor stable.

+ * + *

They are found here instead, while the hints are being written. That happens during the build, + * on an ordinary JVM with the full classpath, so scanning is available -- it is only the image that + * cannot do it. A closure whose own bytecode does not resolve is skipped: the GSP compiler's Ant + * task is on the compile classpath but Ant is not on the runtime one, and registering it would make + * the image analysis parse a class whose supertype is absent, failing the build.

+ * + *

Where the plugins are listed is carried into the image as well, since that is read to find + * them at all and an image carries a resource only when it has been asked to.

+ * + * @since 8.0 + */ +public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar { + + private static final Log logger = LogFactory.getLog(GrailsClosureRuntimeHints.class); + + /** + * Where the plugins are listed, read to find them at all. Written out rather than taken from + * {@code FactoriesLoaderSupport}, whose constant is a Groovy property and so not visible here. + */ + private static final String PLUGIN_LISTING = "META-INF/grails.factories"; + + /** + * Where the framework's closures are found. The first two cover its own packages; the third + * covers plugin descriptors, which a plugin may declare in any package of its choosing -- the + * asset pipeline names its own {@code asset.pipeline}, and its bean definitions are closures + * the container calls while the context is built. + */ + private static final String[] PATTERNS = { + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "grails/**/*_closure*.class", + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/**/*_closure*.class", + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "**/*GrailsPlugin$*_closure*.class" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + hints.resources().registerPattern(PLUGIN_LISTING); + ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader(); + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader); + MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver); + int registered = 0; + for (String pattern : PATTERNS) { + Resource[] resources; + try { + resources = resolver.getResources(pattern); + } + catch (IOException ex) { + logger.warn("Unable to scan for Grails closures matching " + pattern, ex); + continue; + } + for (Resource resource : resources) { + String className = classNameOf(metadataReaderFactory, resource); + if (className != null && registrable(className, resource, loader)) { + hints.reflection().registerTypeIfPresent(loader, className, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + registered++; + } + } + } + logger.debug("Registered " + registered + " Grails closures for reflection"); + } + + /** + * Whether the closure can be kept. Both questions are asked: the GSP compiler's task extends an + * Ant type absent at run time and its closures reach it through invokedynamic, so nothing in + * their own bytecode reveals it, while the JSP closures load cleanly and name an absent class in + * a method body. + */ + private boolean registrable(String className, Resource resource, ClassLoader loader) { + if (!RegistrableTypes.loads(className, loader) || !enclosingLoads(className, loader)) { + return false; + } + try { + return RegistrableTypes.referencesLoad(resource.getInputStream(), loader); + } + catch (IOException ex) { + return false; + } + } + + /** + * Whether the class the closure was written inside can be loaded. + * + *

A closure loads without it -- it extends {@code Closure} and nothing else -- so asking only + * about the closure lets one through whose surroundings are absent. Registering it makes the + * image analyse it, and analysing a closure means reading the method it was written in: the test + * support ships closures written inside a Spock specification, and an application that does not + * test with Spock has no {@code spock.lang.Specification} for the image to read, which fails the + * build rather than the closure.

+ */ + boolean enclosingLoads(String className, ClassLoader loader) { + int closure = className.indexOf("$_"); + if (closure < 0) { + return true; + } + return RegistrableTypes.loads(className.substring(0, closure), loader); + } + + @Nullable + private String classNameOf(MetadataReaderFactory factory, Resource resource) { + try { + return factory.getMetadataReader(resource).getClassMetadata().getClassName(); + } + catch (IOException | RuntimeException ex) { + return null; + } + } + +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java new file mode 100644 index 00000000000..6eb63af240d --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHints.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.SpringProperties; +import org.springframework.util.StringUtils; + +/** + * The resources a Grails application reads by name at run time, which an image cannot prove are + * reached. + * + *

An image includes what it can prove is used. A compiled asset is asked for by request path and + * a message bundle by locale, so nothing in the code names either, and an image built without them + * starts and then serves every page with a missing stylesheet and an untranslated string.

+ * + *

Registered as hints rather than passed as {@code -H:IncludeResources}: that option is + * experimental and GraalVM now warns it will require unlocking, while hints are the supported way + * and are what Spring writes the image's resource configuration from.

+ * + *

Bounded to where those two are actually looked for. A pattern that matched every properties + * file at every depth would carry the whole classpath into the image -- every dependency's + * configuration, and whatever else happened to be packaged beside it -- to keep the few that are + * message bundles.

+ * + * @since 8.0 + */ +public class GrailsResourceRuntimeHints implements RuntimeHintsRegistrar { + + /** + * Where a message bundle is looked for: the root of the classpath, which is what + * {@code PluginAwareResourceBundleMessageSource} scans and where a plugin's own bundles land. + * Not below it, because nothing reads them there. + */ + private static final String MESSAGE_BUNDLES = "*.properties"; + + /** Compiled assets, which asset-pipeline serves from the classpath by the path asked for. */ + private static final String[] ASSETS = { "assets/*", "assets/**" }; + + /** + * Where an application says what else it reads by name, as a comma-separated list of patterns. + * Read as a property rather than from configuration because this runs while the code is being + * generated rather than while the application is running, so it is set on the task that does + * the generating: + * + *
+     * tasks.named('processAot') {
+     *     systemProperty 'grails.aot.resource-patterns', 'db/migration/**,templates/*.vm'
+     * }
+     * 
+ */ + public static final String ADDITIONAL_PATTERNS_PROPERTY = "grails.aot.resource-patterns"; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String pattern : ASSETS) { + hints.resources().registerPattern(pattern); + } + hints.resources().registerPattern(MESSAGE_BUNDLES); + for (String pattern : additionalPatterns()) { + hints.resources().registerPattern(pattern); + } + } + + /** + * What the application asked for on top of the above, or none where it asked for nothing. + * + *

Tokenized rather than split, so that a list written with spaces after its commas is the + * list it looks like: a pattern carrying a leading space matches nothing, and does so silently, + * leaving out exactly the resource the application asked to keep.

+ */ + private String[] additionalPatterns() { + String configured = SpringProperties.getProperty(ADDITIONAL_PATTERNS_PROPERTY); + if (!StringUtils.hasText(configured)) { + return new String[0]; + } + return StringUtils.tokenizeToStringArray(configured, ","); + } + +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java new file mode 100644 index 00000000000..c5c07fc72b5 --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashSet; +import java.util.Properties; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import org.grails.aot.RegistrableTypes; + +/** + * Registers the classes that extend types Groovy did not declare. + * + *

A module declares them in a descriptor, and Groovy calls the method it finds there through the + * metaclass rather than as a call written against the class -- so {@code request.forwardURI} reaches + * a static method on an extension class reflectively. An image keeps that method only when asked, + * and nothing asks: the extension class is named in a descriptor rather than in any code.

+ * + *

The image then refuses the call where it is made. The framework extends the servlet request, + * the response, the session and more this way, and those are reached while rendering a page, so what + * fails is a request rather than the start-up -- most visibly the error page, which is the one page + * that renders when something has already gone wrong.

+ * + *

Read from the descriptors while the hints are written, so a module added later is covered + * without being named here, whether it belongs to the framework, a plugin or the application.

+ * + *

The descriptors themselves are carried into the image too, along with the table of methods + * Groovy adds to every type. Both are read as the runtime starts, and an image carries a resource + * only when it has been asked to: without them Groovy cannot build its metaclasses at all, and + * fails before any application code runs.

+ * + * @since 8.0 + */ +public class GroovyExtensionModuleRuntimeHints implements RuntimeHintsRegistrar { + + private static final Log logger = LogFactory.getLog(GroovyExtensionModuleRuntimeHints.class); + + private static final String DESCRIPTOR_NAME = "org.codehaus.groovy.runtime.ExtensionModule"; + + /** Both places Groovy reads module descriptors from. */ + private static final String[] DESCRIPTORS = { + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "META-INF/services/" + DESCRIPTOR_NAME, + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "META-INF/groovy/" + DESCRIPTOR_NAME + }; + + /** The two kinds of extension a descriptor names: one extends instances, the other the type. */ + private static final String[] CLASS_PROPERTIES = { "extensionClasses", "staticExtensionClasses" }; + + /** + * What the Groovy runtime reads as it starts: the table of the methods it adds to every type, + * the version it reports, and the descriptors naming the extensions above. + */ + private static final String[] RESOURCES = { + "META-INF/dgminfo", + "META-INF/groovy-release-info.properties", + "META-INF/services/" + DESCRIPTOR_NAME, + "META-INF/groovy/" + DESCRIPTOR_NAME + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String resource : RESOURCES) { + hints.resources().registerPattern(resource); + } + ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader(); + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader); + Set extensionClasses = new LinkedHashSet<>(); + for (String descriptor : DESCRIPTORS) { + collectFrom(resolver, descriptor, extensionClasses); + } + int registered = 0; + for (String className : extensionClasses) { + if (RegistrableTypes.loads(className, loader)) { + hints.reflection().registerTypeIfPresent(loader, className, + MemberCategory.INVOKE_DECLARED_METHODS); + registered++; + } + } + logger.debug("Registered " + registered + " Groovy extension classes for reflection"); + } + + private void collectFrom(ResourcePatternResolver resolver, String descriptor, Set collected) { + Resource[] resources; + try { + resources = resolver.getResources(descriptor); + } + catch (IOException ex) { + logger.warn("Unable to read Groovy extension modules from " + descriptor, ex); + return; + } + for (Resource resource : resources) { + Properties module = read(resource); + if (module == null) { + continue; + } + for (String property : CLASS_PROPERTIES) { + for (String className : StringUtils.commaDelimitedListToStringArray( + module.getProperty(property, ""))) { + String trimmed = className.trim(); + if (!trimmed.isEmpty()) { + collected.add(trimmed); + } + } + } + } + } + + /** A descriptor that cannot be read names nothing, which is what an unreadable one contributes. */ + @Nullable + private Properties read(Resource resource) { + try (InputStream input = resource.getInputStream()) { + Properties module = new Properties(); + module.load(input); + return module; + } + catch (IOException | RuntimeException ex) { + logger.warn("Unable to read Groovy extension module " + resource, ex); + return null; + } + } +} diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java new file mode 100644 index 00000000000..6f5add205ac --- /dev/null +++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot; + +import java.lang.reflect.Array; +import java.lang.reflect.Executable; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotContribution; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.aot.BeanRegistrationCode; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; +import org.springframework.beans.factory.config.ConstructorArgumentValues; +import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.javapoet.CodeBlock; +import org.springframework.util.ClassUtils; + +/** + * Gathers a variable-argument constructor argument into the array it feeds, ahead of time. + * + *

A bean declared through the plugin DSL passes its arguments positionally, and a constructor + * that ends in a variable-argument parameter is called the way the language allows: one value where + * the parameter is an array, or a collection where it is an array of that element type. Building the + * bean, Spring adapts the argument to the parameter. Reading the definition to generate code for it, + * Spring does not: it looks the argument up by the parameter's type, and a lone {@code String} does + * not answer to {@code String[]}.

+ * + *

The argument is then missed and resolved as a dependency instead, and an array of a type nobody + * publishes as a bean resolves to an empty array rather than failing. So the bean is built, and + * built wrong: a datastore that maps no classes, or a servlet registration with no URL mapping, + * which then falls back to mapping everything. Nothing is logged, and the bean that goes wrong is + * rarely the one that reports it -- the first symptom is a page that 404s or a domain class that + * says it is not one.

+ * + *

Gathering the argument into an array here means the generator writes out {@code new String[] + * {"*.gsp"}}, which the lookup does find. Only an argument that is already usable as the array is + * left alone, and an argument that would need its elements converted is left to the resolution that + * exists today rather than guessed at here.

+ * + * @since 8.0 + */ +public class VarargsBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor { + + @Override + @Nullable + public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { + Executable executable = resolveExecutable(registeredBean); + if (executable == null || !executable.isVarArgs()) { + return null; + } + Class[] parameterTypes = executable.getParameterTypes(); + RootBeanDefinition beanDefinition = registeredBean.getMergedBeanDefinition(); + Object gathered = gatherTrailingArgument(beanDefinition.getConstructorArgumentValues(), parameterTypes); + if (gathered == null) { + return null; + } + return BeanRegistrationAotContribution.withCustomCodeFragments( + codeFragments -> new VarargsCodeFragments(codeFragments, gathered)); + } + + /** + * The constructor or factory method the generator will write the call to. + * + *

Resolution reads the bean class and its members, so a bean whose class cannot be resolved + * fails here rather than at the point of use. It is not this processor's place to report that: + * generation carries on and fails where it means something.

+ */ + @Nullable + private Executable resolveExecutable(RegisteredBean registeredBean) { + try { + return registeredBean.resolveConstructorOrFactoryMethod(); + } + catch (Throwable ignored) { + return null; + } + } + + /** + * The trailing argument as the array its parameter takes, or {@code null} to leave it alone. + * + *

Only the straightforward reading is handled: one argument per parameter, the last of them + * standing for the variable-argument array. An argument list that spreads several values across + * that parameter, or that is indexed rather than positional, is left as it is.

+ */ + @Nullable + private Object gatherTrailingArgument(ConstructorArgumentValues arguments, Class[] parameterTypes) { + if (!arguments.getIndexedArgumentValues().isEmpty()) { + return null; + } + List supplied = arguments.getGenericArgumentValues(); + if (supplied.size() != parameterTypes.length) { + return null; + } + Class arrayType = parameterTypes[parameterTypes.length - 1]; + if (!arrayType.isArray()) { + return null; + } + Object value = supplied.get(supplied.size() - 1).getValue(); + if (value == null || ClassUtils.isAssignableValue(arrayType, value)) { + return null; + } + Class componentType = arrayType.getComponentType(); + Collection elements = value instanceof Collection collection ? collection : List.of(value); + return toArray(elements, componentType); + } + + /** + * The elements as an array of the component type, or {@code null} if any of them is not already + * one. Converting an element is the resolution step's job, and guessing at it here would turn a + * missed argument into a wrong one. + */ + @Nullable + private Object toArray(Collection elements, Class componentType) { + for (Object element : elements) { + if (element == null || !ClassUtils.isAssignableValue(componentType, element)) { + return null; + } + } + Object array = Array.newInstance(componentType, elements.size()); + int index = 0; + for (Object element : elements) { + Array.set(array, index++, element); + } + return array; + } + + /** Writes the definition out with the gathered argument in place of the one supplied. */ + private static final class VarargsCodeFragments extends BeanRegistrationCodeFragmentsDecorator { + + private final Object gathered; + + private VarargsCodeFragments(BeanRegistrationCodeFragments delegate, Object gathered) { + super(delegate); + this.gathered = gathered; + } + + @Override + public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext generationContext, + BeanRegistrationCode beanRegistrationCode, RootBeanDefinition beanDefinition, + Predicate attributeFilter) { + return super.generateSetBeanDefinitionPropertiesCode(generationContext, beanRegistrationCode, + withGatheredArgument(beanDefinition), attributeFilter); + } + + /** + * A copy carrying the gathered argument, so the definition the context is running on keeps + * the argument it was given and only the generated code differs. + */ + private RootBeanDefinition withGatheredArgument(RootBeanDefinition beanDefinition) { + RootBeanDefinition copy = new RootBeanDefinition(beanDefinition); + List supplied = copy.getConstructorArgumentValues().getGenericArgumentValues(); + supplied.get(supplied.size() - 1).setValue(this.gathered); + return copy; + } + } +} diff --git a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json index ec339dce9a7..d6119f9aa04 100644 --- a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -2,15 +2,38 @@ "properties": [ { "name": "grails.banner.art.display", - "description": "Whether to display the Grails banner art.", + "description": "Whether to display the Grails banner art.", "type": "java.lang.Boolean", "defaultValue": true }, { - "name": "grails.banner.art.file", - "description": "The file path on the classpath to the Grails banner art to display.", - "type": "java.lang.String", - "defaultValue": "grails-banner.txt" + "name": "grails.banner.art.file", + "description": "The file path on the classpath to the Grails banner art to display.", + "type": "java.lang.String", + "defaultValue": "grails-banner.txt" + }, + { + "name": "grails.banner.art.color", + "type": "java.lang.String", + "description": "The colour to show the banner art in, where the terminal colours output at all. A number selects one of the 256 colours a terminal offers; a name selects one of the eight it has always had, such as red or bright_blue; none leaves the art uncoloured. A value that is neither falls back to the default.", + "defaultValue": "214" + }, + { + "name": "grails.banner.mark.display", + "type": "java.lang.Boolean", + "description": "Whether to say under the banner art how the application was started: NATIVE for an image, AOT CACHE for a JVM given a cache to read, AOT for one running generated bean definitions. An ordinary start says nothing.", + "defaultValue": true + }, + { + "name": "grails.banner.mark.color", + "type": "java.lang.String", + "description": "The colour to show the mark in, separate from the art so it is not read as part of it. Takes the same values as grails.banner.art.color: a number for one of the 256 colours a terminal offers, a name for one of the eight it has always had, or none.", + "defaultValue": "226" + }, + { + "name": "grails.banner.mark.text", + "type": "java.lang.String", + "description": "Says this instead of what was detected. Shown spaced and in upper case, and an empty value shows nothing." }, { "name": "grails.banner.versions.display", @@ -56,11 +79,23 @@ { "name": "grails.i18n.localeResolver", "values": [ - { "value": "session", "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." }, - { "value": "cookie", "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." }, - { "value": "acceptHeader", "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." }, - { "value": "fixed", "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." } + { + "value": "session", + "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." + }, + { + "value": "cookie", + "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." + }, + { + "value": "acceptHeader", + "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." + }, + { + "value": "fixed", + "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." + } ] } ] -} \ No newline at end of file +} diff --git a/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties new file mode 100644 index 00000000000..966754ebc3c --- /dev/null +++ b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties @@ -0,0 +1,30 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# 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. +# +# Both of these read their surroundings the first time they are touched, so an image that +# initialized them while it was being built would answer for the machine that built it. +# +# - Groovy's plugin factory decides how it reaches the JDK it is running on, and an image +# initialized at build time carries a decision made against the build's JDK. Nothing then +# starts: its metaclass registry throws a NullPointerException before any application code runs. +# - Grails' build settings resolve the project directory, which exists only where the build ran. +# +# Args rather than hints because when a class is initialized is a property of the image, and +# nothing in the Spring hint model expresses it. +Args = --initialize-at-run-time=org.codehaus.groovy.vmplugin.VMPluginFactory \ + --initialize-at-run-time=grails.util.BuildSettings diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..fe2a8c4bea9 --- /dev/null +++ b/grails-core/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,17 @@ +org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\ +org.grails.spring.beans.aot.AbstractBeanDefinitionExcludeFilter + +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.spring.beans.aot.BeanRegistrarRuntimeHints,\ +org.grails.spring.beans.aot.GrailsApiRuntimeHints,\ +org.grails.spring.beans.aot.GrailsBannerRuntimeHints,\ +org.grails.spring.beans.aot.GrailsClosureRuntimeHints,\ +org.grails.spring.beans.aot.GrailsResourceRuntimeHints,\ +org.grails.spring.beans.aot.GroovyExtensionModuleRuntimeHints + +org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\ +org.grails.spring.beans.aot.AutowireModeBeanRegistrationAotProcessor,\ +org.grails.spring.beans.aot.VarargsBeanRegistrationAotProcessor + +org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\ +org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy new file mode 100644 index 00000000000..13321c4ae2c --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.boot + +import org.springframework.boot.ansi.Ansi8BitColor +import org.springframework.boot.ansi.AnsiColor +import org.springframework.boot.ansi.AnsiOutput +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment +import spock.lang.Specification + +/** + * Covers the banner art being shown in the framework's colour. + * + *

The escapes are written only where colour has been enabled, and the width the versions beneath + * are centred on is measured before they are added -- otherwise they would count towards it and + * push the versions off centre by as many characters as the colour cost.

+ */ +class GrailsBannerColourSpec extends Specification { + + GrailsBanner banner = new GrailsBanner() + + StandardEnvironment environment = new StandardEnvironment() + + void cleanup() { + AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT) + } + + private void configured(String colour) { + environment.propertySources.addFirst( + new MapPropertySource('test', ['grails.banner.art.color': colour])) + } + + private String printed() { + ByteArrayOutputStream bytes = new ByteArrayOutputStream() + banner.printBanner(environment, GrailsBannerColourSpec, new PrintStream(bytes)) + bytes.toString() + } + + void 'the art is the framework amber where nothing is configured'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + + expect: + banner.colour('art', environment) + .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + } + + void 'the art is left as it stands where colour is off'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER) + + expect: 'a redirected log, or an application that has turned colour off' + banner.colour('art', environment) == 'art' + } + + void 'an application chooses one of the 256 colours by number'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + configured('45') + + expect: + banner.colour('art', environment) + .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(45))) + } + + void 'an application chooses one of the eight by name'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + configured('bright_blue') + + expect: 'read without regard to case, as configuration is written either way' + banner.colour('art', environment) + .startsWith(AnsiOutput.encode(AnsiColor.BRIGHT_BLUE)) + } + + void 'an application asks for no colour at all'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + configured('none') + + expect: 'colour everywhere else, and a banner left as it stands' + banner.colour('art', environment) == 'art' + } + + void 'a colour that means nothing falls back rather than failing to start'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + configured('chartreuse') + + expect: + banner.colour('art', environment) + .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + } + + void 'a number outside the 256 a terminal has falls back'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + + expect: 'anything else writes an escape the terminal does not understand, which it then shows' + banner.colour('art', configuredWith('999')) + .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + banner.colour('art', configuredWith('-1')) + .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + } + + /** A fresh environment each time, so the two values above do not fight over one property source. */ + private StandardEnvironment configuredWith(String colour) { + StandardEnvironment fresh = new StandardEnvironment() + fresh.propertySources.addFirst(new MapPropertySource('test', ['grails.banner.art.color': colour])) + fresh + } + + void 'the banner carries the colour through to what is printed'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + + expect: + printed().contains(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + } + + void 'the versions stay where they were before the art was coloured'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER) + List plain = versionLines(printed()) + + when: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + List coloured = versionLines(printed().replaceAll(/\[[0-9;]*m/, '')) + + then: 'the width is measured before the escapes are added, so they do not count towards it' + coloured == plain + !plain.isEmpty() + } + + /** The centred lines beneath the art, which is where a mismeasured width would show. */ + private List versionLines(String output) { + output.readLines().findAll { it.contains('Grails:') || it.contains('Spring') } + } +} diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy new file mode 100644 index 00000000000..6640184ac9a --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerNativeMarkSpec.groovy @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.boot + +import org.springframework.boot.ansi.Ansi8BitColor +import org.springframework.boot.ansi.AnsiOutput +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment +import spock.lang.Specification + +/** + * Covers the banner saying how an application was started, under the art and above the versions. + * + *

Written plainly. A banner is printed on the thread that is starting the application, so + * anything drawn over time here is time the application is not starting.

+ */ +class GrailsBannerNativeMarkSpec extends Specification { + + /** A banner told what it was started as, so the marks can be covered without being one. */ + static class StartedAs extends GrailsBanner { + + boolean image + boolean cache + + @Override + protected boolean isNativeImage() { + image + } + + @Override + protected boolean readsAotCache() { + cache + } + } + + StandardEnvironment environment = new StandardEnvironment() + + void cleanup() { + AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT) + System.clearProperty('spring.aot.enabled') + } + + private void configured(Map properties) { + environment.propertySources.addFirst(new MapPropertySource('test', properties)) + } + + private String printed(GrailsBanner banner) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream() + banner.printBanner(environment, GrailsBannerNativeMarkSpec, new PrintStream(bytes)) + bytes.toString() + } + + private String plain(GrailsBanner banner) { + AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER) + printed(banner) + } + + void 'an ordinary start says nothing'() { + expect: 'no image, no cache, and bean definitions worked out as it went' + String output = plain(new StartedAs()) + !output.contains('N A T I V E') + !output.contains('A O T') + } + + void 'an image says so'() { + expect: + plain(new StartedAs(image: true)).contains('N A T I V E') + } + + void 'a run given a cache to read says so'() { + expect: + plain(new StartedAs(cache: true)).contains('A O T C A C H E') + } + + void 'a run on generated bean definitions says so'() { + given: 'the smaller half of the same idea, and a thing an application can be run without' + System.setProperty('spring.aot.enabled', 'true') + + expect: + plain(new StartedAs()).contains('A O T') + } + + void 'an image outranks a cache, which outranks generated definitions'() { + given: + System.setProperty('spring.aot.enabled', 'true') + + expect: 'an image has already done all of it, so it is the only thing worth saying' + plain(new StartedAs(image: true, cache: true)).contains('N A T I V E') + } + + void 'the mark sits under the art and above the versions'() { + when: + String output = plain(new StartedAs(image: true)) + + then: + output.indexOf('N A T I V E') > output.indexOf('grails.apache.org') + output.indexOf('N A T I V E') < output.indexOf('JVM') + } + + void 'the mark is centred on the art'() { + when: + List lines = plain(new StartedAs(image: true)).readLines() + String mark = lines.find { it.contains('N A T I V E') } + int artWidth = lines*.length().max() + + then: 'the same space either side, give or take the odd character' + int leading = mark.length() - mark.stripLeading().length() + int trailing = artWidth - mark.length() + Math.abs(leading - trailing) <= 1 + } + + void 'nothing is drawn over, wherever it is read'() { + when: 'a terminal, a redirected log, or an application that has turned colour off' + String output = plain(new StartedAs(image: true)) + + then: 'one word, once' + output.count('N A T I V E') == 1 + + and: 'nothing drawn over -- a return only ever ends a line, never rewinds one' + // A banner is printed with println, so the line ending is the platform's: \n where + // this is read on Linux or macOS, \r\n on Windows. What over-drawing looks like is a + // return on its own, so that is what this asks about, on every platform alike. + !output.replace('\r\n', '\n').contains('\r') + } + + void 'an application can say something else'() { + expect: + configured(['grails.banner.mark.text': 'Leyden']) + plain(new StartedAs(cache: true)).contains('L e y d e n') + } + + void 'a mark is spaced as it was written rather than shouted'() { + expect: 'a name whose case is part of it keeps it -- CRaC is not CRAC' + configured(['grails.banner.mark.text': 'CRaC']) + plain(new StartedAs()).contains('C R a C') + } + + void 'the marks it works out for itself are still upper case'() { + expect: 'they are written that way, rather than made that way' + plain(new StartedAs(image: true)).contains('N A T I V E') + plain(new StartedAs(cache: true)).contains('A O T C A C H E') + } + + void 'the mark is a different colour from the art'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + + when: 'the art amber and the mark bright yellow, so one is not read as part of the other' + String output = printed(new StartedAs(image: true)) + String markLine = output.readLines().find { it.contains('N A T I V E') } + + then: + markLine.contains(AnsiOutput.encode(Ansi8BitColor.foreground(226))) + !markLine.contains(AnsiOutput.encode(Ansi8BitColor.foreground(214))) + } + + void 'an application can choose the colour the mark is shown in'() { + given: + AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS) + configured(['grails.banner.mark.color': '39']) + + expect: + printed(new StartedAs(image: true)).readLines() + .find { it.contains('N A T I V E') } + .contains(AnsiOutput.encode(Ansi8BitColor.foreground(39))) + } + + void 'spring security is a default rather than something to ask for'() { + expect: 'so an application with it on the classpath shows it without configuring anything' + GrailsBanner.DefaultVersionOption.values()*.key.contains('spring-security') + !GrailsBanner.OptionalVersionOption.values()*.key.contains('spring-security') + } + + void 'the container served on is a default, and naming a particular one stays optional'() { + expect: 'every application has a container, so it is shown without being asked for' + GrailsBanner.DefaultVersionOption.values()*.key.contains('container') + + and: 'and the specific ones remain, for an application that wants one whatever is serving' + GrailsBanner.OptionalVersionOption.values()*.key == ['tomcat', 'jetty', 'undertow'] + } + + void 'a library that is not there is left out rather than shown as unknown'() { + expect: 'jetty and undertow are not on this classpath; asking for them says nothing' + configured(['grails.banner.versions.include': 'jetty,undertow']) + String output = plain(new StartedAs()) + !output.contains('Jetty') + !output.contains('Undertow') + } + + void 'an application can turn spring security off'() { + expect: 'it is a default now, so exclude is what removes it' + configured(['grails.banner.versions.exclude': 'spring-security']) + !plain(new StartedAs()).contains('Spring Security') + } + + void 'an application can turn the mark off altogether'() { + expect: + configured(['grails.banner.mark.display': false]) + !plain(new StartedAs(image: true)).contains('N A T I V E') + } +} diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy new file mode 100644 index 00000000000..f018478bedb --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerVersionLookupSpec.groovy @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.boot + +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment +import spock.lang.Specification + +/** + * Covers reading an optional library's version without setting the library going. + * + *

A version is read about a library, not from it. Initialising the class to + * find one lets it do whatever it does on the way: Spring Security logs + * {@code You are running with Spring Security Core ...} from its static initialiser, and that line + * arrived in the middle of the banner -- between the mark and the very versions it was being read + * for -- because the lookup had initialised it.

+ */ +class GrailsBannerVersionLookupSpec extends Specification { + + /** + * Where the record is kept, so that reading it is not itself what sets {@link Noisy} going. + * Asking the class under test whether it was initialised initialises it. + */ + static class Record { + + static boolean noisyRan = false + } + + /** Stands in for a library that does something when it starts. */ + static class Noisy { + + static { + Record.noisyRan = true + } + } + + void 'reading a version does not set the library going'() { + when: + GrailsBanner.findVersion(Noisy.name) + + then: 'loaded, but its static initialiser has not run' + !Record.noisyRan + + and: 'and it really would have run, had the class been initialised' + Class.forName(Noisy.name, true, getClass().classLoader) + Record.noisyRan + } + + void 'a library that is not there has no version rather than an error'() { + expect: 'nothing, so the banner leaves it out rather than saying it does not know' + GrailsBanner.findVersion('com.example.NotOnTheClasspath') == null + } + + void 'a library with no version in its manifest has none'() { + expect: 'test classes carry no Implementation-Version, and that is not a failure' + GrailsBanner.findVersion(Noisy.name) == null + } + + void 'a version recorded in a manifest is read'() { + expect: 'Spock ships one, so this proves the lookup reads rather than always saying unknown' + GrailsBanner.findVersion(Specification.name) ==~ /\d+\..*/ + } + + void 'a version recorded in a resource is read'() { + expect: 'the route a container version takes, which a manifest cannot take out of a jar' + GrailsBanner.findVersionInResource('grails/boot/version-in-a-resource.properties', + 'server.number') == '1.2.3' + } + + void 'a resource that is not there has no version rather than an error'() { + expect: + GrailsBanner.findVersionInResource('grails/boot/no-such-resource.properties', + 'server.number') == null + } + + void 'a resource that records no such version has none'() { + expect: 'a library may ship the file and still not answer this question' + GrailsBanner.findVersionInResource('grails/boot/version-in-a-resource.properties', + 'server.unrecorded') == null + } + + /** A banner told which containers are there, and remembering which it was asked about. */ + static class Serving extends GrailsBanner { + + Map present = [:] + + List asked = [] + + @Override + protected String findTomcatVersion() { + asked << 'tomcat' + present['tomcat'] + } + + @Override + protected String findJettyVersion() { + asked << 'jetty' + present['jetty'] + } + + @Override + protected String findUndertowVersion() { + asked << 'undertow' + present['undertow'] + } + } + + private static StandardEnvironment configured(Map properties = [:]) { + StandardEnvironment environment = new StandardEnvironment() + environment.propertySources.addFirst(new MapPropertySource('test', properties)) + environment + } + + void 'the container being served on is shown, named as itself'() { + expect: + new Serving(present: ['tomcat': '1.1.1']).findContainerVersion() == ['Tomcat': '1.1.1'] + } + + void 'the ones it cannot also be running on are not looked for'() { + given: 'an application serves on one container, so the second is not a question worth asking' + Serving banner = new Serving(present: ['tomcat': '1.1.1', 'jetty': '2.2.2']) + + when: + Map container = banner.findContainerVersion() + + then: 'the most commonly used of the two, and only that one shown' + container == ['Tomcat': '1.1.1'] + + and: 'and only that one asked about' + banner.asked == ['tomcat'] + } + + void 'the next one is tried when the one before it is not there'() { + expect: + new Serving(present: ['jetty': '2.2.2']).findContainerVersion() == ['Jetty': '2.2.2'] + new Serving(present: ['undertow': '3.3.3']).findContainerVersion() == ['Undertow': '3.3.3'] + } + + void 'an application on none of them says nothing rather than that it does not know'() { + when: + Serving banner = new Serving() + + then: + banner.findContainerVersion().isEmpty() + + and: 'having asked about each, since any of them could have been the one' + banner.asked == ['tomcat', 'jetty', 'undertow'] + } + + void 'the container is shown without an application asking for it'() { + expect: + new Serving(present: ['tomcat': '1.1.1']).createBannerVersions(configured())['Tomcat'] == '1.1.1' + } + + void 'an application can leave the container out'() { + expect: 'one key covers whichever container it is, so this is how it is turned off' + !new Serving(present: ['tomcat': '1.1.1']) + .createBannerVersions(configured(['grails.banner.versions.exclude': 'container'])) + .containsKey('Tomcat') + } + + void 'naming the container as well as being shown it by default shows it once'() { + when: 'an application that asked for tomcat before it was shown by default keeps working' + Serving banner = new Serving(present: ['tomcat': '1.1.1']) + ByteArrayOutputStream bytes = new ByteArrayOutputStream() + banner.printBanner(configured(['grails.banner.versions.include': 'tomcat']), + GrailsBannerVersionLookupSpec, new PrintStream(bytes)) + + then: + bytes.toString().count('Tomcat') == 1 + } + + void 'an option that has moved between the lists is still recognised'() { + expect: 'spring-security was asked for before it was shown by default, and an application ' + + 'that still asks for it named something this understands' + new Serving().unrecognisedVersionOptions( + configured(['grails.banner.versions.include': 'spring-security'])).isEmpty() + } + + void 'an option shown by default can be named in either list'() { + expect: 'which list an option belongs to is this class\'s decision to change, so naming one ' + + 'is not a mistake an application made' + new Serving().unrecognisedVersionOptions(configured([ + 'grails.banner.versions.include': 'container', + 'grails.banner.versions.exclude': 'tomcat'])).isEmpty() + } + + void 'an option that names nothing is reported'() { + expect: 'dropped silently, a typo reads as a banner ignoring what it was told, and the only ' + + 'way to find out is to notice a line that is not there' + new Serving().unrecognisedVersionOptions( + configured(['grails.banner.versions.include': 'spring-securty'])) == ['spring-securty'] + } + + void 'every place an option can be written is checked'() { + expect: + new Serving().unrecognisedVersionOptions(configured([ + 'grails.banner.versions.order': 'grails,ordr', + 'grails.banner.versions.exclude': 'excloode', + 'grails.banner.versions.include': 'includ'])) == ['ordr', 'excloode', 'includ'] + } + + void 'an application that named them all correctly is told nothing'() { + expect: + new Serving().unrecognisedVersionOptions(configured([ + 'grails.banner.versions.order': 'grails,groovy', + 'grails.banner.versions.exclude': 'app', + 'grails.banner.versions.include': 'jetty'])).isEmpty() + } +} diff --git a/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy new file mode 100644 index 00000000000..7789f062c17 --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/GrailsApplicationPostProcessorAnnotationConfigSpec.groovy @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.boot.config + +import org.springframework.aot.AotDetector +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.context.annotation.AnnotationConfigUtils +import spock.lang.Specification + +/** + * Covers the processors that read the injection annotations being restored when running on + * artifacts generated ahead of time. + * + *

The generator resolves the annotations itself for a bean whose implementation it can see, but a + * bean contributed as an interface built by a supplier hides that implementation, and the annotated + * members on it are then injected by nobody.

+ */ +class GrailsApplicationPostProcessorAnnotationConfigSpec extends Specification { + + private static final String AOT_KEY = 'spring.aot.enabled' + + BeanDefinitionRegistry registry = new DefaultListableBeanFactory() + + /** Restores the property, so the environment other specs observe is unchanged. */ + private void withGeneratedArtifacts(boolean enabled, Closure body) { + String previous = System.getProperty(AOT_KEY) + try { + enabled ? System.setProperty(AOT_KEY, 'true') : System.clearProperty(AOT_KEY) + body.call() + } + finally { + previous == null ? System.clearProperty(AOT_KEY) : System.setProperty(AOT_KEY, previous) + } + } + + private void register() { + GrailsApplicationPostProcessor.registerAnnotationConfigProcessorsForGeneratedArtifacts(registry) + } + + void 'the injection processors are registered when running on generated artifacts'() { + when: + withGeneratedArtifacts(true) { register() } + + then: + registry.containsBeanDefinition(AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME) + registry.containsBeanDefinition(AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME) + } + + void 'the processor that reads configuration classes is not'() { + when: + withGeneratedArtifacts(true) { register() } + + then: 'reading them again in a context whose configuration is already generated makes a ' + + 'second definition for beans the generated code has contributed' + !registry.containsBeanDefinition(AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME) + } + + void 'nothing is registered without generated artifacts'() { + when: + withGeneratedArtifacts(false) { register() } + + then: + !registry.containsBeanDefinition(AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME) + !registry.containsBeanDefinition(AnnotationConfigUtils.COMMON_ANNOTATION_PROCESSOR_BEAN_NAME) + } + + void 'a context that already has them keeps what it has'() { + given: + RootBeanDefinition existing = new RootBeanDefinition(String) + registry.registerBeanDefinition( + AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME, existing) + + when: + withGeneratedArtifacts(true) { register() } + + then: + registry.getBeanDefinition( + AnnotationConfigUtils.AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME).is(existing) + } +} diff --git a/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy new file mode 100644 index 00000000000..97bedd9ac8d --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.boot.config + +import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier +import org.springframework.boot.bootstrap.DefaultBootstrapContext +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment +import spock.lang.Specification + +import org.apache.grails.core.plugins.DefaultPluginDiscovery +import org.apache.grails.core.plugins.PluginDiscovery + +/** + * Covers an image colouring its output when it is being watched at a terminal. + * + *

Spring Boot decides by asking for the console, and an image answers that it has none even when + * it has one -- so the same application whose start-up is coloured under bootRun arrives plain once + * it is built. What the environment names as the terminal is read instead, which an image does + * carry. It cannot tell output being watched from output being redirected -- nothing in an image + * can -- but it does tell a shell from a build, a container or a service manager.

+ */ +class GrailsEnvironmentPostProcessorAnsiSpec extends Specification { + + private static final String ANSI = 'spring.output.ansi.enabled' + + StandardEnvironment environment = new StandardEnvironment() + + /** + * Runs the post-processor the way Spring Boot runs it, rather than reaching past it for the one + * method under test: whether an image colours its output is only worth knowing about through the + * hook that decides it. + */ + private String ansiAfter(boolean image, String terminal) { + DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext() + bootstrapContext.register(PluginDiscovery, InstanceSupplier.of(noPlugins())) + new Processor(bootstrapContext, image, terminal).postProcessEnvironment(environment, null) + environment.getProperty(ANSI) + } + + /** Plugin discovery that finds none, so the phase this shares with completes and does nothing. */ + private static PluginDiscovery noPlugins() { + DefaultPluginDiscovery discovery = new DefaultPluginDiscovery(new Class[0]) + discovery.loadPluginsFromClasspath = false + discovery + } + + void 'an image at a terminal colours its output'() { + expect: + ansiAfter(true, 'xterm-256color') == 'always' + } + + void 'an image where nothing names a terminal stays plain'() { + expect: 'a build, a container or a service manager, whose output is only ever read later' + ansiAfter(true, null) == null + } + + void 'a terminal that cannot colour is left alone'() { + expect: + ansiAfter(true, 'dumb') == null + } + + void 'running on a JVM is left to Spring Boot'() { + expect: 'where asking for the console works, and answers for pipes too' + ansiAfter(false, 'xterm-256color') == null + } + + void 'an application that has said either way keeps what it said'() { + given: + environment.propertySources.addFirst(new MapPropertySource('test', [(ANSI): 'never'])) + + expect: + ansiAfter(true, 'xterm-256color') == 'never' + } + + /** Stands in for the two things only a run can answer. */ + static class Processor extends GrailsEnvironmentPostProcessor { + + private final boolean image + private final String terminal + + Processor(DefaultBootstrapContext bootstrapContext, boolean image, String terminal) { + super(bootstrapContext) + this.image = image + this.terminal = terminal + } + + @Override + protected boolean isImage() { + image + } + + @Override + protected String terminal() { + terminal + } + } +} diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy new file mode 100644 index 00000000000..32395f089a5 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins + +import org.springframework.aot.AotDetector +import org.springframework.aot.generate.ClassNameGenerator +import org.springframework.aot.generate.DefaultGenerationContext +import org.springframework.aot.generate.InMemoryGeneratedFiles +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.support.BeanRegistryAdapter +import org.springframework.beans.factory.support.GenericBeanDefinition +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.context.annotation.AnnotationConfigUtils +import org.springframework.context.aot.ApplicationContextAotGenerator +import org.springframework.context.support.GenericApplicationContext +import org.springframework.core.SpringProperties +import org.springframework.javapoet.ClassName + +import grails.core.DefaultGrailsApplication +import grails.core.GrailsApplication +import grails.plugins.DefaultGrailsPluginManager +import grails.plugins.GrailsPlugin +import grails.plugins.GrailsPluginManager +import org.apache.grails.core.plugins.DefaultPluginDiscovery +import spock.lang.Specification + +/** + * Covers the core plugin's behaviour under Spring's ahead-of-time processing: the bean definitions + * it contributes must be expressible as generated code, and the configuration class post-processor + * it registers must stand down where the context already has its bean definitions generated. + */ +class CoreGrailsPluginAotSpec extends Specification { + + GenericApplicationContext context = new GenericApplicationContext() + + void cleanup() { + SpringProperties.setProperty(AotDetector.AOT_ENABLED, null) + context.close() + } + + /** + * Mirrors the registrar phase of {@code GrailsApplicationPostProcessor}: every enabled plugin's + * {@link BeanRegistrar} applied against the registry through the same adapter the runtime uses. + */ + private void applyCorePluginRegistrar() { + GrailsApplication application = new DefaultGrailsApplication() + application.applicationContext = context + application.initialise() + + def discovery = new DefaultPluginDiscovery([CoreGrailsPlugin] as Class[]) + discovery.loadPluginsFromClasspath = false + discovery.init(context.environment) + + GrailsPluginManager pluginManager = new DefaultGrailsPluginManager(application, discovery) + context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application) + context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager) + pluginManager.loadPlugins() + + for (GrailsPlugin plugin : pluginManager.allPlugins) { + BeanRegistrar registrar = plugin.beanRegistrar + if (registrar != null) { + new BeanRegistryAdapter(context, context, context.environment, registrar.getClass()) + .register(registrar) + } + } + } + + void 'the configuration class post-processor is registered when generated artifacts are not in use'() { + given: + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + + when: + applyCorePluginRegistrar() + + then: 'plugin-contributed @Configuration beans still need parsing at runtime' + context.containsBeanDefinition('grailsConfigurationClassPostProcessor') + } + + void 'the configuration class post-processor is withheld when generated artifacts are in use'() { + given: 'the flag an AOT-optimized application is started with' + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'true') + + when: + applyCorePluginRegistrar() + + then: 'the configuration classes were parsed at build time, so parsing them again would ' + + 'collide with the definitions already generated' + !context.containsBeanDefinition('grailsConfigurationClassPostProcessor') + } + + void 'a second configuration class post-processor is not added to a context that has one'() { + given: 'the processor Spring registers as part of setting up annotation configuration' + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + AnnotationConfigUtils.registerAnnotationConfigProcessors(context) + + when: + applyCorePluginRegistrar() + + then: 'the one already there sees the plugin definitions, which are registered ahead of it; ' + + 'a second parses the same registry again and, while code is being generated, writes ' + + 'out the same import-aware post-processor a second time' + !context.containsBeanDefinition('grailsConfigurationClassPostProcessor') + context.containsBeanDefinition(AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME) + } + + void "a datastore's own proxy handler is not replaced"() { + given: 'what a GORM implementation declares from doWithSpring, which runs earlier' + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + context.registerBeanDefinition('proxyHandler', new RootBeanDefinition(String)) + + when: + applyCorePluginRegistrar() + + then: 'it knows how to unwrap that datastore\'s proxies, and this one does not' + context.getBeanDefinition('proxyHandler').beanClassName == String.name + } + + void 'the proxy handler is registered where no datastore declared one'() { + given: + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + + when: + applyCorePluginRegistrar() + + then: + context.containsBeanDefinition('proxyHandler') + } + + void 'the core plugin bean definitions can be generated ahead of time'() { + given: + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + applyCorePluginRegistrar() + + and: + def generationContext = new DefaultGenerationContext( + new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'CoreAotTest')), + new InMemoryGeneratedFiles()) + + when: 'the context is processed exactly as the processAot build task processes it' + new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext) + + then: 'no definition holds a value the generator cannot express as code -- a live instance ' + + 'passed as a constructor argument or property value would fail here' + noExceptionThrown() + } + + void 'a definition holding a live instance fails generation'() { + given: + SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false') + applyCorePluginRegistrar() + + and: 'the shape this plugin must avoid: an already-constructed object as a constructor argument' + def definition = new GenericBeanDefinition() + definition.beanClass = StringBuilder + definition.constructorArgumentValues.addIndexedArgumentValue(0, new DefaultGrailsApplication()) + context.registerBeanDefinition('holdsALiveInstance', definition) + + and: + def generationContext = new DefaultGenerationContext( + new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'LiveInstanceAotTest')), + new InMemoryGeneratedFiles()) + + when: + new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext) + + then: 'proving the preceding check is capable of failing' + Exception e = thrown() + e.message.contains('holdsALiveInstance') + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy new file mode 100644 index 00000000000..de2e1d2dd73 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessorSpec.groovy @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans + +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.context.aot.AbstractAotProcessor +import spock.lang.Specification + +/** + * Covers the search locations a resource locator inherits. + * + *

They are directories on the machine this runs on, and a child definition merges them in. + * Generating code for that child writes them into it, so an application would carry the directory it + * was built in and look there for its resources -- a path that says where it was built and, wherever + * it runs, is not where its resources are.

+ */ +class AbstractResourceLocatorPostProcessorSpec extends Specification { + + BeanDefinitionRegistry registry = new DefaultListableBeanFactory() + + void cleanup() { + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + } + + private void whileGeneratingCode(boolean generating) { + generating ? System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true') + : System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + } + + private List registeredSearchLocations() { + registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME) + .propertyValues.getPropertyValue('searchLocations').value as List + } + + void 'the locations are inherited on an ordinary start'() { + given: + whileGeneratingCode(false) + + when: + new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry) + + then: + registeredSearchLocations() == ['/base'] + } + + void 'no location is inherited while code is being generated'() { + given: + whileGeneratingCode(true) + + when: + new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry) + + then: 'a generated application reads its resources from its own contents, and the directory ' + + 'it was built in belongs to the machine that built it' + registeredSearchLocations().isEmpty() + } + + void 'the definition is abstract, so it is inherited rather than built'() { + when: + new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry) + + then: + registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME).abstract + } + + void 'a definition that is already registered is kept'() { + given: + RootBeanDefinition existing = new RootBeanDefinition() + existing.abstract = true + registry.registerBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME, existing) + + when: + new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(registry) + + then: 'which is how an application overrides where its resources are looked for' + registry.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME).is(existing) + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy new file mode 100644 index 00000000000..c0b0ee9de52 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import spock.lang.Specification + +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.GenericBeanDefinition +import org.springframework.beans.factory.support.RegisteredBean + +import org.grails.spring.beans.AbstractResourceLocatorPostProcessor + +class AbstractBeanDefinitionExcludeFilterSpec extends Specification { + + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory() + AbstractBeanDefinitionExcludeFilter filter = new AbstractBeanDefinitionExcludeFilter() + + void 'an abstract definition is excluded from AOT processing'() { + given: 'a classless template definition carrying only inherited property values' + def definition = new GenericBeanDefinition() + definition.abstract = true + definition.propertyValues.add('searchLocations', ['/some/location']) + beanFactory.registerBeanDefinition('abstractParent', definition) + + expect: + filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'abstractParent')) + } + + void 'a concrete definition is left to AOT processing'() { + given: + def definition = new GenericBeanDefinition() + definition.beanClass = String + beanFactory.registerBeanDefinition('concrete', definition) + + expect: + !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'concrete')) + } + + void 'a child inheriting from an abstract parent is left to AOT processing'() { + given: 'the parent template and a child naming it' + def parent = new GenericBeanDefinition() + parent.abstract = true + parent.propertyValues.add('searchLocations', ['/some/location']) + beanFactory.registerBeanDefinition('abstractParent', parent) + + def child = new GenericBeanDefinition() + child.beanClass = StringBuilder + child.parentName = 'abstractParent' + beanFactory.registerBeanDefinition('child', child) + + expect: 'the child is generated from its merged definition, so it needs no parent at runtime' + !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'child')) + } + + void 'the resource locator template the core plugin contributes is excluded'() { + given: + new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(beanFactory) + + expect: + filter.isExcludedFromAotProcessing( + RegisteredBean.of(beanFactory, AbstractResourceLocatorPostProcessor.BEAN_NAME)) + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy new file mode 100644 index 00000000000..dd2813dacc0 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.generate.ClassNameGenerator +import org.springframework.aot.generate.DefaultGenerationContext +import org.springframework.aot.generate.GeneratedFiles +import org.springframework.aot.generate.InMemoryGeneratedFiles +import org.springframework.context.aot.ApplicationContextAotGenerator +import org.springframework.context.support.GenericApplicationContext +import org.springframework.javapoet.ClassName +import spock.lang.Specification + +import grails.core.DefaultGrailsApplication +import grails.core.GrailsApplication +import grails.plugins.GrailsPlugin +import grails.plugins.GrailsPluginManager + +/** + * Covers the artefacts an application is made of being written down while they can still be found. + * + *

They are found by walking the classpath and by reading a list the compile-time transform builds + * as it goes, and an image has neither -- so it found no controllers, no domain classes and no URL + * mappings, and the application failed to start on the first bean that wanted one. The only way to + * start was for an application to name its own artefacts by hand.

+ */ +class ArtefactClassesBeanFactoryInitializationAotProcessorSpec extends Specification { + + GenericApplicationContext context = new GenericApplicationContext() + + void cleanup() { + context.close() + } + + private String generatedSourceFor(Class... artefacts) { + GrailsApplication application = new DefaultGrailsApplication(artefacts) + application.applicationContext = context + application.initialise() + context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application) + + InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles() + DefaultGenerationContext generationContext = new DefaultGenerationContext( + new ClassNameGenerator(ClassName.get('com.example', 'Subject')), generatedFiles) + new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext) + generationContext.writeGeneratedContent() + + generatedFiles.getGeneratedFiles(GeneratedFiles.Kind.SOURCE) + .keySet() + .collect { generatedFiles.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) } + .join('\n') + } + + void 'the artefacts are written into the generated code'() { + when: + String generated = generatedSourceFor(DemoController, DemoService) + + then: 'so that an image has them without the application naming them itself' + generated.contains('registerSingleton("grailsArtefactClasses"') + generated.contains('DemoController.class') + generated.contains('DemoService.class') + } + + void 'the registration is run as the bean factory is initialized'() { + when: + String generated = generatedSourceFor(DemoController) + + then: 'they are read while the definitions are still being contributed, so they have to be ' + + 'there before any of them is' + generated.contains('registerArtefactClasses') + } + + void 'a context that is not a Grails application contributes nothing'() { + given: + def processor = new ArtefactClassesBeanFactoryInitializationAotProcessor() + + expect: 'a plain Spring application being generated has no grailsApplication to ask' + processor.processAheadOfTime(context.beanFactory) == null + } + + void 'the classes that constitute the application are what is written down'() { + when: 'a Grails application holds these as the classes it is made of' + String generated = generatedSourceFor(DemoController, DemoService) + + then: 'which is what classes() answers with, so a run reads what a build found' + generated.count('.class') >= 2 + } + + void 'an application with no artefacts contributes nothing'() { + given: + GrailsApplication application = new DefaultGrailsApplication() + application.applicationContext = context + application.initialise() + context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application) + + expect: 'writing an empty array down would say the application has none, which is different ' + + 'from not having looked' + new ArtefactClassesBeanFactoryInitializationAotProcessor() + .processAheadOfTime(context.beanFactory) == null + } + + void "what the plugins bring with them is not written down"() { + given: "a plugin manager whose plugins provide one of the classes the application holds" + GrailsApplication application = new DefaultGrailsApplication(DemoController, DemoService) + application.applicationContext = context + application.initialise() + context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application) + context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, + Stub(GrailsPluginManager) { + getAllPlugins() >> ([Stub(GrailsPlugin) { + getProvidedArtefacts() >> ([DemoService] as Class[]) + }] as GrailsPlugin[]) + }) + + when: + def contribution = new ArtefactClassesBeanFactoryInitializationAotProcessor() + .processAheadOfTime(context.beanFactory) + + then: "they are registered from the plugins on every start, and a codec or tag library " + + "registered twice is not the same as registered once" + contribution != null + + and: + def written = writtenBy(contribution) + written.contains(DemoController) + !written.contains(DemoService) + } + + /** The classes the contribution would write, read back off the code it generates. */ + private List writtenBy(contribution) { + InMemoryGeneratedFiles files = new InMemoryGeneratedFiles() + DefaultGenerationContext generation = new DefaultGenerationContext( + new ClassNameGenerator(ClassName.get('com.example', 'Written')), files) + new ApplicationContextAotGenerator().processAheadOfTime(context, generation) + generation.writeGeneratedContent() + String source = files.getGeneratedFiles(GeneratedFiles.Kind.SOURCE) + .keySet() + .collect { files.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) } + .join('\n') + [DemoController, DemoService].findAll { source.contains(it.simpleName + '.class') } + } + + static class DemoController { + } + + static class DemoService { + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy new file mode 100644 index 00000000000..f08b4db347d --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AutowireModeBeanRegistrationAotProcessorSpec.groovy @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.beans.factory.support.AbstractBeanDefinition +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RegisteredBean +import org.springframework.beans.factory.support.RootBeanDefinition +import spock.lang.Specification + +/** + * Covers a bean's autowire mode reaching the code generated for it. + * + *

Grails registers much of what it contributes as autowired by name, and the generator writes out + * most of a definition but not that, so a bean rebuilt from generated code would arrive with those + * collaborators unset. Nothing fails at start-up; the first request that reaches one does.

+ */ +class AutowireModeBeanRegistrationAotProcessorSpec extends Specification { + + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory() + + AutowireModeBeanRegistrationAotProcessor processor = new AutowireModeBeanRegistrationAotProcessor() + + private RegisteredBean register(String name, int autowireMode) { + RootBeanDefinition definition = new RootBeanDefinition(Collaborating) + definition.autowireMode = autowireMode + beanFactory.registerBeanDefinition(name, definition) + RegisteredBean.of(beanFactory, name) + } + + void 'a bean autowired by name is contributed to'() { + expect: + processor.processAheadOfTime(register('byName', AbstractBeanDefinition.AUTOWIRE_BY_NAME)) != null + } + + void 'a bean autowired by type is contributed to'() { + expect: + processor.processAheadOfTime(register('byType', AbstractBeanDefinition.AUTOWIRE_BY_TYPE)) != null + } + + void 'a bean that is not autowired is left alone'() { + expect: 'contributing to every bean would put a redundant assignment in every definition' + processor.processAheadOfTime(register('plain', AbstractBeanDefinition.AUTOWIRE_NO)) == null + } + + static class Collaborating { + + String collaborator + + void setCollaborator(String collaborator) { + this.collaborator = collaborator + } + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..e0204de73ca --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import java.util.function.Consumer + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeHint +import org.springframework.aot.hint.TypeReference +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry +import org.springframework.beans.factory.ListableBeanFactory +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import spock.lang.Specification + +/** + * Covers the registry a plugin declares its beans against being callable from an image. + * + *

A plugin declares them in a closure, so every call on the registry is made reflectively. An + * image keeps a method for that only when asked, nothing else asks for these, and the failure is a + * context that does not start naming an interface the application never mentions.

+ */ +class BeanRegistrarRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new BeanRegistrarRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private TypeHint hintFor(Class type) { + hints.reflection().getTypeHint(TypeReference.of(type)) + } + + void 'the registry a plugin declares its beans against can be called'() { + expect: + hintFor(BeanRegistry)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the specification a plugin configures a bean through can be called'() { + expect: 'registerBean(name, type) { ... } calls onto it for every bean so declared' + hintFor(BeanRegistry.Spec)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + hintFor(BeanRegistry.SupplierContext)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'what hands a plugin to the registry can be called'() { + expect: + hintFor(BeanRegistrar)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the registry the older bean DSL is handed can be called'() { + expect: 'a plugin that still declares its beans that way asks whether one is already there' + hintFor(BeanDefinitionRegistry)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + hintFor(ListableBeanFactory)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + hintFor(ConfigurableListableBeanFactory)?.memberCategories + ?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the call a plugin actually makes is covered'() { + given: 'the three-argument form, which is what a bean with a specification uses' + def registerBean = BeanRegistry.getMethod('registerBean', String, Class, Consumer) + + expect: 'declared methods covers it, so the image keeps it' + registerBean.declaringClass == BeanRegistry + hintFor(BeanRegistry).memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..2ff8daf8fb1 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +import org.springframework.context.ApplicationContext +import org.springframework.core.env.Environment +import org.springframework.core.env.PropertyResolver + +import grails.config.Config +import grails.config.ConfigMap +import grails.core.GrailsApplication +import grails.plugins.GrailsPluginManager + +/** + * Covers the framework's own interfaces being callable from an image. + * + *

A plugin descriptor is Groovy written largely without static compilation, so reading a setting + * or asking the application about its artefacts is resolved where the call is written and made + * reflectively. An application never names these interfaces itself, so nothing else asks an image to + * keep them, and a context stopped starting on ConfigMap.getProperty -- how nearly every plugin + * reads its settings.

+ */ +class GrailsApiRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new GrailsApiRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private boolean invocable(Class type) { + def hint = hints.reflection().getTypeHint(TypeReference.of(type)) + hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the configuration a plugin reads its settings from can be called'() { + expect: + invocable(ConfigMap) + invocable(Config) + } + + void 'what a plugin asks about the application can be called'() { + expect: + invocable(GrailsApplication) + } + + void 'what a plugin asks about the plugins can be called'() { + expect: + invocable(GrailsPluginManager) + } + + void 'what Spring hands a plugin can be called'() { + expect: 'a descriptor is given these and calls them the same dynamic way' + invocable(PropertyResolver) + invocable(Environment) + invocable(ApplicationContext) + } + + void 'the call that stopped a context from starting is covered'() { + given: 'the form a plugin uses to read a setting with a type and a default' + def getProperty = ConfigMap.getMethod('getProperty', String, Class, Object) + + expect: + getProperty.declaringClass == ConfigMap + invocable(ConfigMap) + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..7545a368bfa --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsBannerRuntimeHintsSpec.groovy @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeHint +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +import grails.boot.GrailsBanner + +/** + * Covers the banner's option enums surviving into an image. + * + *

A Groovy enum reaches its own constructor through the metaclass to build its constants, so an + * image that kept no constructor for it fails in the static initialiser -- while printing the + * banner, before the application has run. The optional enum is the one that bites: it is touched + * only where {@code grails.banner.versions.include} names something, so a traced image carries no + * record of it and turning a version on turns the application off.

+ */ +class GrailsBannerRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new GrailsBannerRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private TypeHint hintFor(Class type) { + hints.reflection().getTypeHint(TypeReference.of(type)) + } + + void 'every option enum can be constructed'() { + expect: 'the constants are built by a static initialiser that goes through the constructor' + hintFor(type)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) + + where: + type << [GrailsBanner.VersionOption, GrailsBanner.DefaultVersionOption, + GrailsBanner.OptionalVersionOption] + } + + void 'the optional enum is registered though nothing reaches it unless it is configured'() { + expect: 'an image traced with no versions included would otherwise have no record of it' + hintFor(GrailsBanner.OptionalVersionOption) != null + } + + void 'the enums can be read and called'() { + expect: 'values() and the key each constant carries' + hintFor(type)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS) + hintFor(type)?.memberCategories?.contains(MemberCategory.ACCESS_DECLARED_FIELDS) + + where: + type << [GrailsBanner.VersionOption, GrailsBanner.DefaultVersionOption, + GrailsBanner.OptionalVersionOption] + } + + void 'the metaclass name Groovy looks up is registered'() { + expect: 'looked up before Groovy decides the enum has no metaclass of its own' + hints.reflection().getTypeHint(TypeReference.of( + "groovy.runtime.metaclass.${GrailsBanner.OptionalVersionOption.name}MetaClass")) != null + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..c489ed120d7 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +/** + * Covers the framework's closures being found while the hints are written, rather than named one by + * one. Groovy reads {@code doCall}'s parameter types to choose an overload, so a closure missing + * from an image fails where it is used rather than at start-up. + */ +class GrailsClosureRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + private List registeredTypes() { + hints.reflection().typeHints().collect { it.type.name } + } + + void 'the framework closures on the classpath are registered'() { + when: + new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader) + + then: 'this module alone ships plenty, so a scan that found nothing would be broken' + registeredTypes().count { it.contains('_closure') } > 0 + } + + void 'a registered closure can have its parameter types read'() { + given: + new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader) + + when: + def closure = registeredTypes().find { it.contains('_closure') } + def hint = hints.reflection().getTypeHint(TypeReference.of(closure)) + + then: 'that is the access Groovy needs to select an overload' + hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'only framework and plugin descriptor closures are registered'() { + when: + new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader) + + then: 'a plugin may sit in any package, but nothing else should be swept up' + registeredTypes().every { + it.startsWith('grails.') || it.startsWith('org.grails.') || it.contains('GrailsPlugin$') + } + } + + void 'where the plugins are listed is carried'() { + given: + new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader) + + expect: 'read to find the plugins at all, and an image carries a resource only when asked' + RuntimeHintsPredicates.resource().forResource('META-INF/grails.factories').test(hints) + } + + void 'a closure is skipped when the class it was written inside cannot be loaded'() { + given: + def registrar = new GrailsClosureRuntimeHints() + ClassLoader loader = getClass().classLoader + + expect: 'analysing a closure means reading the method it was written in, so a closure whose ' + + 'surroundings are absent fails the build rather than being left out of it' + !registrar.enclosingLoads('com.example.NotHere$_someMethod_closure1', loader) + + and: 'one written inside a class that is present is kept' + registrar.enclosingLoads('org.grails.config.NavigableMap$_flattenKeys_closure3', loader) + + and: 'a class that is not a closure has no surroundings to ask about' + registrar.enclosingLoads('org.grails.config.NavigableMap', loader) + } + + void 'a class loader that resolves nothing yields no hints rather than failing'() { + when: + new GrailsClosureRuntimeHints().registerHints(hints, new URLClassLoader(new URL[0], null)) + + then: + noExceptionThrown() + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..c2a4c9f4470 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsResourceRuntimeHintsSpec.groovy @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.hint.RuntimeHints +import org.springframework.core.SpringProperties +import spock.lang.Specification + +/** + * Covers the resources an application reads by name surviving into an image, and only those. + * + *

An asset is asked for by request path and a message bundle by locale, so nothing in the code + * names either and an image built without them serves every page with a missing stylesheet and an + * untranslated string. What is registered has to be bounded all the same: a pattern matching every + * properties file at every depth carries the whole classpath into the image to keep the few that + * are bundles.

+ */ +class GrailsResourceRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void cleanup() { + SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, null) + } + + /** + * What was registered. Spring records the parent directory of each pattern alongside it, so + * these are what was asked for and not only what this class named. + */ + private Set registeredPatterns() { + new GrailsResourceRuntimeHints().registerHints(hints, getClass().classLoader) + hints.resources().resourcePatternHints().toList() + .collectMany { it.includes } + .collect { it.pattern } as Set + } + + void 'the compiled assets are registered'() { + expect: 'asset-pipeline serves them from the classpath by the path that was asked for' + registeredPatterns().containsAll(['assets/*', 'assets/**']) + } + + void 'the message bundles at the root of the classpath are registered'() { + expect: 'which is what PluginAwareResourceBundleMessageSource scans, and where a plugin ' + + "shipping its own bundle lands them" + '*.properties' in registeredPatterns() + } + + void 'every properties file on the classpath is not'() { + expect: 'nothing reads a bundle below the root, and registering the pattern would carry ' + + 'every dependency configuration into the image to keep the few that are bundles' + !('**/*.properties' in registeredPatterns()) + } + + void 'an application says what else it reads by name'() { + given: + SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, + 'db/migration/**,templates/*.vm') + + expect: 'read as a property because this runs while the code is being generated' + registeredPatterns().containsAll(['db/migration/**', 'templates/*.vm']) + } + + void 'a list written with spaces after its commas is the list it looks like'() { + given: + SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, + 'db/migration/**, templates/*.vm ,') + + expect: 'a pattern carrying a leading space matches nothing, and does so silently' + registeredPatterns().containsAll(['db/migration/**', 'templates/*.vm']) + !registeredPatterns().any { it != it.trim() } + } + + void 'an application that says nothing gets what it would have'() { + given: + SpringProperties.setProperty(GrailsResourceRuntimeHints.ADDITIONAL_PATTERNS_PROPERTY, blank) + + expect: + registeredPatterns().containsAll(['assets/*', 'assets/**', '*.properties']) + !('db/migration/**' in registeredPatterns()) + + where: + blank << [null, '', ' '] + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..d73e1b14617 --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates +import spock.lang.Specification + +/** + * Covers the classes that extend types Groovy did not declare being callable from an image. + * + *

A module names them in a descriptor rather than in any code, and Groovy calls them through the + * metaclass, so nothing else asks an image to keep them. The failure is a request rather than the + * start-up: the framework extends the servlet request and response this way, and those are reached + * while a page is rendered.

+ */ +class GroovyExtensionModuleRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new GroovyExtensionModuleRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private Set registeredTypes() { + hints.reflection().typeHints().collect { it.type.name } as Set + } + + private boolean invocable(String className) { + invocableIn(hints, className) + } + + private boolean invocableIn(RuntimeHints target, String className) { + def hint = target.reflection().getTypeHint(TypeReference.of(className)) + hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the extensions the framework declares are registered'() { + expect: 'these are named only in a descriptor, so nothing else would ask for them' + registeredTypes().any { it.endsWith('Extension') } + } + + void 'both kinds of extension a descriptor names are registered'() { + given: 'a descriptor of this spec\'s own, so the assertion does not rest on what happens to ' + + 'be on the classpath of the module the registrar lives in' + File directory = File.createTempDir() + new File(directory, 'META-INF/services').mkdirs() + new File(directory, 'META-INF/services/org.codehaus.groovy.runtime.ExtensionModule').text = ''' + moduleName=spec-module + moduleVersion=1.0 + extensionClasses=java.lang.StringBuilder + staticExtensionClasses=java.lang.StringBuffer + '''.stripIndent() + RuntimeHints declared = new RuntimeHints() + + when: + new GroovyExtensionModuleRuntimeHints().registerHints(declared, + new URLClassLoader([directory.toURI().toURL()] as URL[], getClass().classLoader)) + + then: 'one extends instances and the other the type; a call is made through either' + invocableIn(declared, 'java.lang.StringBuilder') + invocableIn(declared, 'java.lang.StringBuffer') + + cleanup: + directory.deleteDir() + } + + void 'every extension named by a descriptor can have its methods called'() { + expect: 'registering the type without its methods still leaves the call refused' + registeredTypes().findAll { it.endsWith('Extension') }.every { invocable(it) } + } + + void 'what the Groovy runtime reads as it starts is carried'() { + expect: 'without these it cannot build its metaclasses, and fails before any application code' + resourceRegistered('META-INF/dgminfo') + resourceRegistered('META-INF/groovy-release-info.properties') + + and: 'and the descriptors naming the extensions, which is how it finds them' + resourceRegistered('META-INF/services/org.codehaus.groovy.runtime.ExtensionModule') + resourceRegistered('META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule') + } + + private boolean resourceRegistered(String resource) { + RuntimeHintsPredicates.resource().forResource(resource).test(hints) + } + + void 'a class loader that resolves nothing yields no hints rather than failing'() { + given: + RuntimeHints empty = new RuntimeHints() + + when: + new GroovyExtensionModuleRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null)) + + then: + noExceptionThrown() + } +} diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy new file mode 100644 index 00000000000..f24d672738d --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessorSpec.groovy @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring.beans.aot + +import org.springframework.aot.generate.ClassNameGenerator +import org.springframework.aot.generate.DefaultGenerationContext +import org.springframework.aot.generate.GeneratedFiles +import org.springframework.aot.generate.InMemoryGeneratedFiles +import org.springframework.beans.factory.config.RuntimeBeanReference +import org.springframework.beans.factory.support.AbstractBeanDefinition +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.context.aot.ApplicationContextAotGenerator +import org.springframework.context.support.GenericApplicationContext +import org.springframework.javapoet.ClassName +import spock.lang.Specification + +/** + * Covers a variable-argument constructor argument being gathered into the array it feeds. + * + *

Spring adapts the argument when it builds the bean, but not when it reads the definition to + * generate code for it: it looks the argument up by the parameter's type, misses a value that is not + * already the array, and resolves it as a dependency instead -- which for an array type is an empty + * array. The bean is then built with nothing where its arguments should be, and says so much later.

+ * + *

These read the code that is actually generated, because the failure this guards against is one + * where every bean is still registered and still built.

+ */ +class VarargsBeanRegistrationAotProcessorSpec extends Specification { + + GenericApplicationContext context = new GenericApplicationContext() + + void cleanup() { + context.close() + } + + /** The generated source for a context holding one bean with the given constructor arguments. */ + private String generatedSourceFor(Class beanClass, List arguments, + int autowireMode = AbstractBeanDefinition.AUTOWIRE_NO) { + RootBeanDefinition definition = new RootBeanDefinition(beanClass) + definition.autowireMode = autowireMode + arguments.each { definition.constructorArgumentValues.addGenericArgumentValue(it) } + context.registerBeanDefinition('subject', definition) + + InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles() + DefaultGenerationContext generationContext = new DefaultGenerationContext( + new ClassNameGenerator(ClassName.get('com.example', 'Subject')), generatedFiles) + new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext) + generationContext.writeGeneratedContent() + + generatedFiles.getGeneratedFiles(GeneratedFiles.Kind.SOURCE) + .keySet() + .collect { generatedFiles.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) } + .join('\n') + } + + void 'a lone value is gathered into the array its parameter takes'() { + when: + String generated = generatedSourceFor(Registration, ['*.gsp']) + + then: 'a bare "*.gsp" does not answer to String[], so the generated lookup would miss it' + generated.contains('new String[] {"*.gsp"}') + } + + void 'a collection is gathered into the array of its element type'() { + when: + String generated = generatedSourceFor(Mapped, [[String, Integer]]) + + then: + generated.contains('new Class[] {String.class, Integer.class}') + } + + void 'an argument that is already the array is left as it is'() { + when: + String generated = generatedSourceFor(Registration, [['*.gsp', '*.jsp'] as String[]]) + + then: + generated.contains('new String[] {"*.gsp", "*.jsp"}') + } + + void 'a fixed constructor is untouched'() { + when: + String generated = generatedSourceFor(Fixed, ['one', 'two']) + + then: + generated.contains('addGenericArgumentValue("one")') + generated.contains('addGenericArgumentValue("two")') + !generated.contains('new String[]') + } + + void 'a reference is left for the context to resolve'() { + given: + context.registerBeanDefinition('elsewhere', new RootBeanDefinition(String)) + + when: + String generated = generatedSourceFor(Registration, [new RuntimeBeanReference('elsewhere')]) + + then: 'what it refers to is not known until the context runs, so it cannot be gathered here' + generated.contains('RuntimeBeanReference("elsewhere")') + !generated.contains('new String[]') + } + + void 'an argument whose elements would need converting is left alone'() { + when: 'a String where the array takes Class' + String generated = generatedSourceFor(Mapped, ['java.lang.String']) + + then: 'gathering it would turn an argument that is missed into one that is wrong' + generated.contains('addGenericArgumentValue("java.lang.String")') + !generated.contains('new Class[]') + } + + void 'a bean that is also autowired keeps both contributions'() { + when: 'two processors decorate the same generated properties, one after the other' + String generated = generatedSourceFor(Registration, ['*.gsp'], + AbstractBeanDefinition.AUTOWIRE_BY_NAME) + + then: 'neither displaces the other' + generated.contains('new String[] {"*.gsp"}') + generated.contains("setAutowireMode(${AbstractBeanDefinition.AUTOWIRE_BY_NAME})") + } + + void 'a bean built with no arguments is untouched'() { + when: + String generated = generatedSourceFor(Registration, []) + + then: + !generated.contains('new String[]') + } + + static class Registration { + + Registration(String... urlMappings) { + } + } + + static class Mapped { + + Mapped(Class... classes) { + } + } + + static class Fixed { + + Fixed(String one, String two) { + } + } +} diff --git a/grails-core/src/test/resources/grails/boot/version-in-a-resource.properties b/grails-core/src/test/resources/grails/boot/version-in-a-resource.properties new file mode 100644 index 00000000000..147708d0c3e --- /dev/null +++ b/grails-core/src/test/resources/grails/boot/version-in-a-resource.properties @@ -0,0 +1,5 @@ +# Stands in for a library that records its version in a resource it ships, the way Tomcat records +# its own in org/apache/catalina/util/ServerInfo.properties. Kept here rather than read from a real +# dependency so that the test does not change meaning when a managed version is bumped. +server.number=1.2.3 +server.built=not a version diff --git a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy index 62727e13dc1..ec8f5953d6d 100644 --- a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy +++ b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy @@ -20,7 +20,6 @@ import groovy.transform.CompileStatic import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationEventPublisher import org.springframework.context.support.GenericApplicationContext import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.env.PropertyResolver @@ -135,7 +134,6 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { } Closure getBeanDefinitions(BeanDefinitionRegistry beanDefinitionRegistry) { - ApplicationEventPublisher eventPublisher = super.findEventPublisher(beanDefinitionRegistry) Closure beanDefinitions = { def common = getCommonConfiguration(beanDefinitionRegistry, 'hibernate') common.delegate = delegate @@ -145,13 +143,19 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { hibernateProxyHandler(HibernateProxyHandler) def config = this.configuration + Object configurationReference = configurationReference(beanDefinitionRegistry) final boolean isGrailsPresent = isGrailsPresent() + // Registered rather than built here, so what the datastore holds is a reference the + // container can build. Holding the publisher itself puts a live object in the + // definition, and generating code for a definition means writing out what it holds -- + // which a publisher bound to a running context is not. + grailsDatastoreEventPublisher(findEventPublisherClass(beanDefinitionRegistry)) dataSourceConnectionSourceFactory(CachedDataSourceConnectionSourceFactory) hibernateConnectionSourceFactory(HibernateConnectionSourceFactory, persistentClasses as Class[]) { bean -> bean.autowire = true dataSourceConnectionSourceFactory = ref('dataSourceConnectionSourceFactory') } - hibernateDatastore(HibernateDatastore, config, hibernateConnectionSourceFactory, eventPublisher) { bean -> + hibernateDatastore(HibernateDatastore, configurationReference, hibernateConnectionSourceFactory, ref('grailsDatastoreEventPublisher')) { bean -> bean.primary = true } sessionFactory(hibernateDatastore: 'getSessionFactory') { bean -> diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy index 231da37c875..495b1c9367e 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy @@ -31,6 +31,7 @@ import org.springframework.beans.factory.config.ConstructorArgumentValues import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.core.ResolvableType import org.springframework.core.Ordered import org.springframework.transaction.PlatformTransactionManager @@ -63,7 +64,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi if (!registry.containsBeanDefinition(dataSourceBeanName) && shouldConfigureDataSourceBean) { def dataSourceBean = new RootBeanDefinition() - dataSourceBean.setTargetType(DataSource) + dataSourceBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, DataSource)) dataSourceBean.setBeanClass(InstanceFactoryBean) def args = new ConstructorArgumentValues() String spel = "#{dataSourceConnectionSourceFactory.create('$dataSourceName', environment).source}".toString() @@ -80,7 +81,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi String transactionManagerBeanName = "transactionManager$suffix" def sessionFactoryBean = new RootBeanDefinition() - sessionFactoryBean.setTargetType(SessionFactory) + sessionFactoryBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, SessionFactory)) sessionFactoryBean.setBeanClass(InstanceFactoryBean) def args = new ConstructorArgumentValues() args.addGenericArgumentValue("#{hibernateDatastore.getDatastoreForConnection('$dataSourceName').sessionFactory}".toString()) @@ -93,7 +94,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi ) def transactionManagerBean = new RootBeanDefinition() - transactionManagerBean.setTargetType(PlatformTransactionManager) + transactionManagerBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, PlatformTransactionManager)) transactionManagerBean.setBeanClass(InstanceFactoryBean) def txMgrArgs = new ConstructorArgumentValues() txMgrArgs.addGenericArgumentValue("#{hibernateDatastore.getDatastoreForConnection('$dataSourceName').transactionManager}".toString()) diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrarSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrarSpec.groovy index 7e103bd075e..416fc881a45 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrarSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrarSpec.groovy @@ -30,6 +30,18 @@ import javax.sql.DataSource class HibernateDatastoreConnectionSourcesRegistrarSpec extends HibernateGormDatastoreSpec { + /** + * What a definition says it will produce, without producing it. + * + *

These are factory beans, so the bean a definition stands for is the factory's type + * argument rather than the factory. Naming it on the definition is what lets the type be known + * from the definition alone -- which is what generating bean definitions as code requires, and + * what asking the factory would have prevented, since asking it means creating it.

+ */ + private static Class produces(def definition) { + definition.targetType == InstanceFactoryBean ? definition.resolvableType.getGeneric(0).resolve() : null + } + def "test postProcessBeanDefinitionRegistry registers expected beans"() { given: def registry = new DefaultListableBeanFactory() @@ -44,28 +56,28 @@ class HibernateDatastoreConnectionSourcesRegistrarSpec extends HibernateGormData registry.containsBeanDefinition(Settings.SETTING_DATASOURCE) def defaultDs = registry.getBeanDefinition(Settings.SETTING_DATASOURCE) defaultDs.beanClass == InstanceFactoryBean - defaultDs.targetType == DataSource + produces(defaultDs) == DataSource defaultDs.constructorArgumentValues.genericArgumentValues[0].value == "#{dataSourceConnectionSourceFactory.create('dataSource', environment).source}" // Secondary dataSource bean registry.containsBeanDefinition("${Settings.SETTING_DATASOURCE}_readOnly") def readOnlyDs = registry.getBeanDefinition("${Settings.SETTING_DATASOURCE}_readOnly") readOnlyDs.beanClass == InstanceFactoryBean - readOnlyDs.targetType == DataSource + produces(readOnlyDs) == DataSource readOnlyDs.constructorArgumentValues.genericArgumentValues[0].value == "#{dataSourceConnectionSourceFactory.create('readOnly', environment).source}" // Secondary sessionFactory bean registry.containsBeanDefinition("sessionFactory_readOnly") def readOnlySf = registry.getBeanDefinition("sessionFactory_readOnly") readOnlySf.beanClass == InstanceFactoryBean - readOnlySf.targetType == SessionFactory + produces(readOnlySf) == SessionFactory readOnlySf.constructorArgumentValues.genericArgumentValues[0].value == "#{hibernateDatastore.getDatastoreForConnection('readOnly').sessionFactory}" // Secondary transactionManager bean registry.containsBeanDefinition("transactionManager_readOnly") def readOnlyTm = registry.getBeanDefinition("transactionManager_readOnly") readOnlyTm.beanClass == InstanceFactoryBean - readOnlyTm.targetType == PlatformTransactionManager + produces(readOnlyTm) == PlatformTransactionManager readOnlyTm.constructorArgumentValues.genericArgumentValues[0].value == "#{hibernateDatastore.getDatastoreForConnection('readOnly').transactionManager}" // Default sessionFactory and transactionManager should NOT be registered by this registrar diff --git a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy index 58940a847fb..62763ef2202 100644 --- a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy +++ b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy @@ -21,7 +21,6 @@ import groovy.transform.CompileStatic import org.springframework.beans.factory.support.BeanDefinitionRegistry import grails.spring.BeanBuilder import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationEventPublisher import org.springframework.context.support.GenericApplicationContext import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.env.PropertyResolver @@ -153,7 +152,6 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { } Closure getBeanDefinitions(BeanDefinitionRegistry beanDefinitionRegistry) { - ApplicationEventPublisher eventPublisher = super.findEventPublisher(beanDefinitionRegistry) return { -> def common = getCommonConfiguration(beanDefinitionRegistry, 'hibernate') common.delegate = delegate @@ -165,8 +163,14 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { hibernateBytecodeProvider(GrailsBytecodeProvider) def config = this.configuration + Object configurationReference = configurationReference(beanDefinitionRegistry) final boolean isGrailsPresent = isGrailsPresent() def appContext = this.applicationContext + // Registered rather than built here, so what the datastore holds is a reference the + // container can build. Holding the publisher itself puts a live object in the + // definition, and generating code for a definition means writing out what it holds -- + // which a publisher bound to a running context is not. + grailsDatastoreEventPublisher(findEventPublisherClass(beanDefinitionRegistry)) dataSourceConnectionSourceFactory(CachedDataSourceConnectionSourceFactory) hibernateConnectionSourceFactory(HibernateConnectionSourceFactory, ref('hibernateBytecodeProvider'), persistentClasses as Class[]) { bean -> bean.autowire = true @@ -175,7 +179,7 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { applicationContext = appContext } } - hibernateDatastore(HibernateDatastore, config, hibernateConnectionSourceFactory, eventPublisher) { bean -> + hibernateDatastore(HibernateDatastore, configurationReference, hibernateConnectionSourceFactory, ref('grailsDatastoreEventPublisher')) { bean -> bean.primary = true } sessionFactory(hibernateDatastore: 'getSessionFactory') { bean -> diff --git a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy index 68dfe37d573..87b02fe42bc 100644 --- a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy +++ b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy @@ -22,15 +22,11 @@ import com.mongodb.client.MongoClient import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationEventPublisher -import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.support.GenericApplicationContext import org.springframework.util.ClassUtils import grails.mongodb.MongoEntity import org.grails.datastore.gorm.bootstrap.AbstractDatastoreInitializer -import org.grails.datastore.gorm.events.ConfigurableApplicationContextEventPublisher -import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher import org.grails.datastore.gorm.plugin.support.PersistenceContextInterceptorAggregator import org.grails.datastore.gorm.support.AbstractDatastorePersistenceContextInterceptor import org.grails.datastore.gorm.support.DatastorePersistenceContextInterceptor @@ -89,25 +85,26 @@ class MongoDbDataStoreSpringInitializer extends AbstractDatastoreInitializer { def callable = getCommonConfiguration(beanDefinitionRegistry, 'mongo') callable.delegate = delegate callable.call() - ApplicationEventPublisher eventPublisher - if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) { - eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry) - } - else if (resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext) { - eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) resourcePatternResolver.resourceLoader) - } - else { - eventPublisher = new DefaultApplicationEventPublisher() - } + // The publisher is registered as a definition rather than constructed here so that the + // datastore holds a reference the container can build, which is what lets the context + // be processed ahead of time. Outside an application context there is nothing to + // publish through, so the no-op publisher stands in as it did before. + grailsDatastoreEventPublisher(findEventPublisherClass(beanDefinitionRegistry)) + // The configuration is referenced rather than passed. Passing it puts the resolver + // itself in the definition, and generating code for a definition means writing out + // whatever it holds: an environment carries the machine's own variables, so the + // generated source ends up containing the build machine's environment, and the + // application reads its settings from wherever it was built rather than where it runs. + Object configurationReference = configurationReference(beanDefinitionRegistry) if (mongo == null) { mongoConnectionSourceFactory(MongoConnectionSourceFactory) { bean -> bean.autowire = true } - mongoDatastore(MongoDatastore, configuration, ref('mongoConnectionSourceFactory'), eventPublisher, collectMappedClasses(DATASTORE_TYPE)) + mongoDatastore(MongoDatastore, configurationReference, ref('mongoConnectionSourceFactory'), ref('grailsDatastoreEventPublisher'), mappedClasses(DATASTORE_TYPE)) mongo(mongoDatastore: 'getMongoClient') } else { - mongoDatastore(MongoDatastore, mongo, configuration, eventPublisher, collectMappedClasses(DATASTORE_TYPE)) + mongoDatastore(MongoDatastore, mongo, configurationReference, ref('grailsDatastoreEventPublisher'), mappedClasses(DATASTORE_TYPE)) } mongoMappingContext(mongoDatastore: 'getMappingContext') diff --git a/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy new file mode 100644 index 00000000000..25d749146f6 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.mongodb.bootstrap + +import grails.persistence.Entity +import org.grails.datastore.gorm.events.ConfigurableApplicationContextEventPublisher +import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher +import org.springframework.beans.factory.config.RuntimeBeanReference +import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry +import org.springframework.context.support.GenericApplicationContext +import spock.lang.Specification + +/** + * Covers how the datastore receives its event publisher. The definitions are inspected without + * refreshing the context, so no MongoDB is involved. + */ +class MongoDbDataStoreSpringInitializerBeanDefinitionsSpec extends Specification { + + private static final String PUBLISHER = 'grailsDatastoreEventPublisher' + + void 'inside an application context the publisher is registered as a definition'() { + given: + def registry = new GenericApplicationContext() + + when: + new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry) + + then: 'a definition rather than an already-constructed object, so it can be processed ahead of time' + registry.containsBeanDefinition(PUBLISHER) + registry.getBeanDefinition(PUBLISHER).beanClassName == + ConfigurableApplicationContextEventPublisher.name + + cleanup: + registry.close() + } + + void 'the datastore refers to the publisher by name rather than holding an instance'() { + given: + def registry = new GenericApplicationContext() + + when: + new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry) + def args = registry.getBeanDefinition('mongoDatastore').constructorArgumentValues + + then: + args.genericArgumentValues*.value + .findAll { it instanceof RuntimeBeanReference } + .any { RuntimeBeanReference reference -> reference.beanName == PUBLISHER } + + cleanup: + registry.close() + } + + void 'outside an application context the no-op publisher is used instead'() { + given: 'a registry that is not a context, as when GORM is bootstrapped standalone' + def registry = new SimpleBeanDefinitionRegistry() + + when: + new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry) + + then: 'the context-aware publisher would never be given a context here, and would fail on publish' + registry.getBeanDefinition(PUBLISHER).beanClassName == DefaultApplicationEventPublisher.name + } + + @Entity + static class Person { + String name + } +} diff --git a/grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy b/grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy index 7221625fe6d..19a7df1e52d 100644 --- a/grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy +++ b/grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy @@ -38,7 +38,6 @@ import org.grails.datastore.mapping.services.ServiceDefinition import org.grails.datastore.mapping.services.SoftServiceLoader import org.springframework.beans.factory.groovy.GroovyBeanDefinitionReader import org.springframework.beans.factory.support.BeanDefinitionRegistry -import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ConfigurableApplicationContext import org.springframework.util.ClassUtils @@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends AbstractDatastoreInitializer { callable.delegate = delegate callable.call() - ApplicationEventPublisher eventPublisher - if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) { - eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry) - } else { - eventPublisher = new DefaultApplicationEventPublisher() - } + // Registered rather than built here, so what the datastore holds is a reference the + // container can build. Holding the publisher itself puts a live object in the + // definition, and generating code for a definition means writing out what it holds -- + // which a publisher bound to a running context is not. + // + // The choice is made here rather than through AbstractDatastoreInitializer, because this + // build resolves that class from a released grails-datamapping-core rather than from + // the source beside it, and a method added there is not one this can call. + grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof ConfigurableApplicationContext + ? ConfigurableApplicationContextEventPublisher + : DefaultApplicationEventPublisher) final boolean isRecentGrailsVersion = GrailsVersion.isAtLeastMajorMinor(3, 3) neo4jConnectionSourceFactory(Neo4jConnectionSourceFactory) { bean -> bean.autowire = true } - neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), eventPublisher, collectMappedClasses(DATASTORE_TYPE)) + // The configuration is held rather than named. Naming the environment instead is what + // lets a definition be generated without writing the build machine's own settings into + // it, but that is asked through a method added to AbstractDatastoreInitializer, and this + // build resolves that class from a released grails-datamapping-core rather than from the + // source beside it -- so the call compiles here and goes missing at run time. + neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), ref('grailsDatastoreEventPublisher'), collectMappedClasses(DATASTORE_TYPE)) neo4jMappingContext(neo4jDatastore: "getMappingContext") neo4jTransactionManager(neo4jDatastore: "getTransactionManager") neo4jAutoTimestampEventListener(neo4jDatastore: "getAutoTimestampEventListener") diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/aot/GormRuntimeHints.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/aot/GormRuntimeHints.java new file mode 100644 index 00000000000..2ebfee041e4 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/aot/GormRuntimeHints.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.datastore.gorm.aot; + +import java.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.util.ClassUtils; + +import org.grails.aot.RegistrableTypes; + +/** + * Registers the persistence runtime a datastore reaches through Groovy. + * + *

Reading and writing an entity goes through the mapping context, the persister for its + * datastore, and the query and event machinery around them, and Groovy reaches all of it + * dynamically. Fields are registered as well as methods because a persister hands work to anonymous + * inner classes that read the state they captured as properties -- deleting an entity reads the + * session that way -- and a native image that keeps only the members something asked for leaves the + * class present with that state unreachable.

+ * + *

Which datastore is registered follows from what is on the classpath: the scan covers the shared + * packages, and an implementation puts its own persisters and query support in them, so MongoDB and + * the others are covered without naming any of them here.

+ * + *

Recording this with the framework rather than leaving it to a tracing agent matters because the + * agent records only what ran. Reads are exercised by anything that opens a page; the write and + * delete paths reach persister internals that a read never touches, so they fail for the first + * person who saves or removes a record.

+ * + * @since 8.0 + */ +public class GormRuntimeHints implements RuntimeHintsRegistrar { + + private static final Log logger = LogFactory.getLog(GormRuntimeHints.class); + + /** + * The persistence runtime. Registered by package: naming the types one at a time describes only + * the operations that have been run so far, and every datastore adds its own. + */ + private static final String[] PATTERNS = { + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/datastore/mapping/**/*.class", + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/datastore/gorm/**/*.class" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader(); + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader); + MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver); + int registered = 0; + for (String pattern : PATTERNS) { + Resource[] resources; + try { + resources = resolver.getResources(pattern); + } + catch (IOException ex) { + logger.warn("Unable to scan for the persistence runtime matching " + pattern, ex); + continue; + } + for (Resource resource : resources) { + String className; + try { + className = metadataReaderFactory.getMetadataReader(resource) + .getClassMetadata().getClassName(); + } + catch (IOException | RuntimeException ex) { + continue; + } + if (!RegistrableTypes.loads(className, loader)) { + continue; + } + hints.reflection().registerTypeIfPresent(loader, className, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.ACCESS_DECLARED_FIELDS, + MemberCategory.ACCESS_PUBLIC_FIELDS); + registered++; + } + } + logger.debug("Registered " + registered + " persistence runtime types for reflection"); + } + +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy index 510c47dcdfb..6655a7f1756 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy @@ -24,14 +24,17 @@ import java.beans.Introspector import groovy.transform.CompileDynamic import groovy.transform.CompileStatic +import org.springframework.beans.factory.config.RuntimeBeanReference import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.MessageSource import org.springframework.context.ResourceLoaderAware +import org.springframework.context.aot.AbstractAotProcessor import org.springframework.context.support.GenericApplicationContext import org.springframework.context.support.StaticMessageSource +import org.springframework.core.SpringProperties import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.env.PropertyResolver import org.springframework.core.env.StandardEnvironment @@ -77,6 +80,33 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { Collection persistentClasses = [] Collection packages = [] PropertyResolver configuration = new StandardEnvironment() + + /** + * What a bean definition should hold in place of the configuration itself. + * + *

A definition holding the resolver is a definition holding everything the resolver can + * reach, and generating code for such a definition writes those values out: an environment + * carries the machine's own variables, so the generated source ends up containing the + * environment of whatever machine built it, along with whatever was in it -- credentials among + * them -- and the application then reads its settings from there rather than from where it + * runs.

+ * + *

So while code is being generated, and only then, the context's environment is named + * instead, which leaves the lookup to be made where the application runs. Every other time the + * resolver is held as it always was, which matters for a datastore brought up on its own: its + * configuration is whatever the caller passed and there is no environment holding it.

+ */ + protected Object configurationReference(BeanDefinitionRegistry registry) { + if (!SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING)) { + return configuration + } + boolean insideContainer = registry instanceof ConfigurableApplicationContext || + resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext + insideContainer + ? new RuntimeBeanReference(ConfigurableApplicationContext.ENVIRONMENT_BEAN_NAME) + : configuration + } + boolean registerApplicationIfNotPresent = true Object originalConfiguration @@ -145,6 +175,26 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { return eventPublisher } + /** + * Finds the publisher class a datastore definition should name, in place of a publisher itself. + * + *

The same choice {@link #findEventPublisher} makes -- one that publishes through the + * surrounding context, or the one that publishes nowhere -- but left to the container to build. + * A definition holding an already-constructed publisher is a definition that cannot be generated + * ahead of time: generating one means writing out what it holds, and a publisher bound to a + * running context is not something there is any way to write down.

+ * + * @param beanDefinitionRegistry the registry the definitions are being contributed to + * @return the class to register the publisher bean under + */ + protected Class findEventPublisherClass(BeanDefinitionRegistry beanDefinitionRegistry) { + if (beanDefinitionRegistry instanceof ConfigurableApplicationContext || + resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext) { + return ConfigurableApplicationContextEventPublisher + } + return DefaultApplicationEventPublisher + } + /** * Finds the message source to use * @param beanDefinitionRegistry The registry @@ -266,11 +316,38 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { return {} } + /** + * The classes this datastore maps. + * + * @param datastoreType the datastore the classes are being collected for + * @return the classes, which a datastore's definition takes through {@link #mappedClasses} + */ protected Collection collectMappedClasses(String datastoreType) { - def classes = !secondaryDatastore ? persistentClasses : persistentClasses.findAll() { Class cls -> + !secondaryDatastore ? persistentClasses : persistentClasses.findAll() { Class cls -> isMappedClass(datastoreType, cls) } - return classes + } + + /** + * The classes this datastore maps, as the array its constructor takes. + * + *

The array type is what makes this survive ahead-of-time processing. A datastore takes its + * classes as a variable-argument parameter, so the constructor argument the generator writes out + * is looked up by that parameter's type when the bean is rebuilt. A collection does not answer to + * {@code Class[]}, so the lookup misses it and the argument is resolved as a dependency instead, + * which for an array type means every {@code Class} bean in the context: none. The datastore is + * then built mapping nothing, and the first call on a domain class reports that it is not one.

+ * + *

Kept apart from {@link #collectMappedClasses} rather than folded into it, because that one + * is the seam a datastore outside this repository overrides to say which classes are its own. + * Changing what it returns would leave such an override no longer overriding anything, and + * silently unasked.

+ * + * @param datastoreType the datastore the classes are being collected for + * @return the classes, as the array a datastore's constructor takes + */ + protected Class[] mappedClasses(String datastoreType) { + collectMappedClasses(datastoreType) as Class[] } protected boolean isMappedClass(String datastoreType, Class cls) { diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy index 91073cd1c07..bdfbfab1f98 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy @@ -21,6 +21,8 @@ package org.grails.datastore.gorm.events import groovy.transform.CompileStatic +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware import org.springframework.context.ApplicationEvent import org.springframework.context.ApplicationListener import org.springframework.context.ConfigurableApplicationContext @@ -32,14 +34,26 @@ import org.springframework.context.ConfigurableApplicationContext * @since 6.0 */ @CompileStatic -class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher { +class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher, ApplicationContextAware { - final ConfigurableApplicationContext applicationContext + ConfigurableApplicationContext applicationContext + + /** + * Takes the context from the container. A bean definition built this way holds no + * already-constructed object, which is what allows it to be processed ahead of time. + */ + ConfigurableApplicationContextEventPublisher() { + } ConfigurableApplicationContextEventPublisher(ConfigurableApplicationContext applicationContext) { this.applicationContext = applicationContext } + @Override + void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = (ConfigurableApplicationContext) applicationContext + } + @Override void addApplicationListener(ApplicationListener listener) { this.applicationContext.addApplicationListener(listener) diff --git a/grails-datamapping-core/src/main/resources/META-INF/spring/aot.factories b/grails-datamapping-core/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..2161ffba18f --- /dev/null +++ b/grails-datamapping-core/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.datastore.gorm.aot.GormRuntimeHints diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/aot/GormRuntimeHintsSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/aot/GormRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..3f106982a57 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/aot/GormRuntimeHintsSpec.groovy @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.datastore.gorm.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +/** + * Covers the persistence runtime surviving into an ahead-of-time image. A read exercises little of + * it, so what is missing shows up when a record is written or removed rather than at start-up. + */ +class GormRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new GormRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private Set registeredTypes() { + hints.reflection().typeHints().collect { it.type.name } as Set + } + + private boolean hasCategory(String type, MemberCategory category) { + def hint = hints.reflection().getTypeHint(TypeReference.of(type)) + hint != null && hint.memberCategories.contains(category) + } + + void 'the mapping context a datastore is driven through is registered'() { + expect: + registeredTypes().contains('org.grails.datastore.mapping.model.MappingContext') + } + + void 'fields are registered, not only methods'() { + given: 'a persister hands work to inner classes that read what they captured as properties' + String type = registeredTypes().find { + it.startsWith('org.grails.datastore.mapping.') + } + + expect: + hasCategory(type, MemberCategory.ACCESS_DECLARED_FIELDS) + hasCategory(type, MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the scan reaches the persistence packages and nothing outside them'() { + expect: + registeredTypes().size() > 0 + registeredTypes().every { + it.startsWith('org.grails.datastore.mapping.') || it.startsWith('org.grails.datastore.gorm.') + } + } + + void 'a class loader that resolves nothing yields no hints rather than failing'() { + given: + RuntimeHints empty = new RuntimeHints() + + when: + new GormRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null)) + + then: + noExceptionThrown() + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerConfigurationSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerConfigurationSpec.groovy new file mode 100644 index 00000000000..034f8a0dd94 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerConfigurationSpec.groovy @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.datastore.gorm.bootstrap + +import org.springframework.beans.factory.config.RuntimeBeanReference +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.aot.AbstractAotProcessor +import org.springframework.context.support.GenericApplicationContext +import org.springframework.core.env.MapPropertySource +import spock.lang.Specification + +import org.grails.datastore.gorm.events.ConfigurableApplicationContextEventPublisher +import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher + +/** + * Covers what a datastore's bean definition holds in place of the configuration. + * + *

A definition holding the resolver is a definition holding everything that resolver can reach, + * and generating code for it writes those values out -- the environment of whatever machine ran the + * build, credentials among them, and settings that are meant to differ where the application runs. + * So while code is being generated the environment is named instead. Every other time the resolver + * is held as before, which is what a datastore brought up on its own depends on: its configuration + * is whatever the caller passed, and no environment holds it.

+ */ +class AbstractDatastoreInitializerConfigurationSpec extends Specification { + + GenericApplicationContext context = new GenericApplicationContext() + + void cleanup() { + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + context.close() + } + + private void whileGeneratingCode(boolean generating) { + generating ? System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true') + : System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + } + + private Initializer initializerFor(Map configuration) { + context.environment.propertySources.addFirst(new MapPropertySource('test', configuration)) + new Initializer(configuration) + } + + void 'the configuration is held as it stands on an ordinary start'() { + given: + Initializer initializer = initializerFor(['grails.mongodb.databaseName': 'foo']) + whileGeneratingCode(false) + + when: + Object held = initializer.configurationReferenceFor(context) + + then: 'a datastore brought up on its own has no environment carrying its settings' + held.is(initializer.configuration) + } + + void 'the environment is named while code is being generated'() { + given: + Initializer initializer = initializerFor(['grails.mongodb.databaseName': 'foo']) + whileGeneratingCode(true) + + when: + Object held = initializer.configurationReferenceFor(context) + + then: 'so the lookup is made where the application runs, not written down where it was built' + held instanceof RuntimeBeanReference + ((RuntimeBeanReference) held).beanName == ConfigurableApplicationContext.ENVIRONMENT_BEAN_NAME + } + + void 'a registry that is not a container keeps the configuration'() { + given: + Initializer initializer = new Initializer(['grails.mongodb.databaseName': 'foo']) + whileGeneratingCode(true) + + when: 'a bare registry, with no environment to name' + Object held = initializer.configurationReferenceFor(new DefaultListableBeanFactory()) + + then: + held.is(initializer.configuration) + } + + void 'the classes a datastore maps are the array its constructor takes'() { + given: + Initializer initializer = new Initializer([:]) + initializer.persistentClasses = [String, Integer] + + expect: 'a collection does not answer to Class[], and the argument is then missed when the ' + + 'definition is read to generate code for it' + initializer.mappedClassesFor('mongo') instanceof Class[] + initializer.mappedClassesFor('mongo').toList() == [String, Integer] + } + + void 'a datastore saying which classes are its own is still asked'() { + given: 'the seam a datastore outside this repository overrides' + Initializer initializer = new Overriding([:]) + initializer.persistentClasses = [String, Integer] + + expect: 'folding the array into it would leave such an override overriding nothing' + initializer.mappedClassesFor('mongo').toList() == [Integer] + } + + void 'inside a container the publisher class that publishes through it is named'() { + given: + Initializer initializer = new Initializer([:]) + + expect: 'a class for the container to build, rather than a publisher built here -- which is ' + + 'a live object, and a definition holding one cannot be generated ahead of time' + initializer.eventPublisherClassFor(context) == ConfigurableApplicationContextEventPublisher + } + + void 'outside a container the publisher that publishes nowhere is named'() { + given: + Initializer initializer = new Initializer([:]) + + expect: 'the context-aware one would never be given a context here, and would fail on publish' + initializer.eventPublisherClassFor(new DefaultListableBeanFactory()) == + DefaultApplicationEventPublisher + } + + void 'the resource loader stands in for a registry that is not a container'() { + given: 'how a datastore initialized against a bare registry still reaches the context' + Initializer initializer = new Initializer([:]) + initializer.setResourceLoader(context) + + expect: + initializer.eventPublisherClassFor(new DefaultListableBeanFactory()) == + ConfigurableApplicationContextEventPublisher + } + + /** Reaches the protected members through a subclass, the way a datastore's own initializer does. */ + static class Initializer extends AbstractDatastoreInitializer { + + Initializer(Map configuration) { + super(configuration) + } + + Object configurationReferenceFor(BeanDefinitionRegistry registry) { + configurationReference(registry) + } + + Class eventPublisherClassFor(BeanDefinitionRegistry registry) { + findEventPublisherClass(registry) + } + + Class[] mappedClassesFor(String datastoreType) { + mappedClasses(datastoreType) + } + + @Override + Closure getBeanDefinitions(BeanDefinitionRegistry beanDefinitionRegistry) { + return { } + } + + @Override + Class getPersistenceInterceptorClass() { + Object + } + } + + /** A datastore that says which of the classes are its own, the way an implementation does. */ + static class Overriding extends Initializer { + + Overriding(Map configuration) { + super(configuration) + } + + @Override + protected Collection collectMappedClasses(String datastoreType) { + persistentClasses.findAll { Class cls -> cls != String } + } + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy new file mode 100644 index 00000000000..592f3fbe713 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.datastore.gorm.events + +import org.springframework.context.ApplicationListener +import org.springframework.context.event.ContextRefreshedEvent +import org.springframework.context.support.GenericApplicationContext +import spock.lang.Specification + +/** + * Covers the publisher being built by the container rather than handed a context, which is what + * lets a datastore reference it as a bean definition instead of an already-constructed object. + */ +class ConfigurableApplicationContextEventPublisherSpec extends Specification { + + GenericApplicationContext context = new GenericApplicationContext() + + void cleanup() { + if (context.active) { + context.close() + } + } + + void 'the container supplies the context to a publisher built without one'() { + given: + context.registerBean('grailsDatastoreEventPublisher', ConfigurableApplicationContextEventPublisher) + + when: + context.refresh() + def publisher = context.getBean('grailsDatastoreEventPublisher', + ConfigurableApplicationContextEventPublisher) + + then: 'nothing passed the context in, so only the container callback can have set it' + publisher.applicationContext.is(context) + } + + void 'a publisher built that way still delivers events and listeners'() { + given: + context.registerBean('grailsDatastoreEventPublisher', ConfigurableApplicationContextEventPublisher) + context.refresh() + def publisher = context.getBean('grailsDatastoreEventPublisher', + ConfigurableApplicationContextEventPublisher) + def received = [] + + when: + publisher.addApplicationListener({ event -> received << event } as ApplicationListener) + def event = new ContextRefreshedEvent(context) + publisher.publishEvent(event) + + then: + received == [event] + } + + void 'the constructor taking a context keeps working'() { + given: 'the form callers outside the container still use' + context.refresh() + + when: + def publisher = new ConfigurableApplicationContextEventPublisher(context) + + then: + publisher.applicationContext.is(context) + } +} diff --git a/grails-datamapping-validation/src/main/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHints.java b/grails-datamapping-validation/src/main/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHints.java new file mode 100644 index 00000000000..55df42ba26b --- /dev/null +++ b/grails-datamapping-validation/src/main/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHints.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.gorm.validation.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the constrained property a domain class's constraints are applied to. + * + *

A constraint that names no registered {@code Constraint} is applied by setting a property of + * the same name, through Groovy rather than by a call the compiler can see: + * {@code ((GroovyObject) this).setProperty(constraintName, constrainingValue)}. So + * {@code password: true} is a reflective invocation of {@code setPassword(boolean)}, and an image + * keeps only the members something asks for.

+ * + *

Without these, an application starts and then fails the first time a domain instance is + * validated, naming a setter nobody wrote:

+ * + *
+ * MissingReflectionRegistrationError: Cannot reflectively invoke method
+ * 'public void grails.gorm.validation.DefaultConstrainedProperty.setPassword(boolean)'
+ * 
+ * + *

Registered here rather than left to a tracing agent because an agent records only the + * constraints the run it watched happened to evaluate. Validation is lazy -- the evaluator is built + * the first time something is validated -- so an application whose data already exists never + * validates during tracing and records nothing, and the same image then fails against an empty + * database. Which of the two an image was traced against is not a property anyone would think to + * keep constant.

+ * + * @since 8.0 + */ +public class ConstrainedPropertyRuntimeHints implements RuntimeHintsRegistrar { + + /** + * The types a constraint is set on. Named as strings, and registered only when present, so this + * stays correct for an application that does not have every one of them on its classpath. + */ + private static final String[] CONSTRAINED_TYPES = { + "grails.gorm.validation.DefaultConstrainedProperty", + "grails.gorm.validation.ConstrainedProperty", + "org.grails.datastore.gorm.validation.constraints.builder.ConstrainedPropertyBuilder" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : CONSTRAINED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } + +} diff --git a/grails-datamapping-validation/src/main/resources/META-INF/spring/aot.factories b/grails-datamapping-validation/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..245d6f5cffe --- /dev/null +++ b/grails-datamapping-validation/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +grails.gorm.validation.aot.ConstrainedPropertyRuntimeHints diff --git a/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHintsSpec.groovy b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..2f2a4b47369 --- /dev/null +++ b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/aot/ConstrainedPropertyRuntimeHintsSpec.groovy @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.gorm.validation.aot + +import grails.gorm.validation.DefaultConstrainedProperty + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference + +import spock.lang.Specification + +/** + * Covers a domain class's constraints surviving into an ahead-of-time image. + * + *

A constraint that names no registered Constraint is applied by setting a property of the same + * name through Groovy, so it is a reflective call the compiler never sees. Without these hints an + * application starts and then fails the first time anything is validated.

+ */ +class ConstrainedPropertyRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new ConstrainedPropertyRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private boolean registered(Class type) { + def hint = hints.reflection().getTypeHint(TypeReference.of(type)) + hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_PUBLIC_METHODS) + } + + void 'the property a constraint is set on is registered'() { + expect: 'password: true is a reflective call to setPassword(boolean), which an image keeps ' + + 'only when something asked for it' + registered(DefaultConstrainedProperty) + } + + void 'a setter a constraint is applied through is one of the methods registered'() { + given: 'the constraint that failed in an image, named the way the failure named it' + def setter = DefaultConstrainedProperty.getMethod('setPassword', boolean) + + expect: 'declared methods are registered, so the setter is reachable rather than stripped' + setter != null + hints.reflection().getTypeHint(TypeReference.of(DefaultConstrainedProperty)) + .memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'the builder that applies them is registered'() { + expect: + registered(org.grails.datastore.gorm.validation.constraints.builder.ConstrainedPropertyBuilder) + } +} diff --git a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc index 246639c6a62..009af0509fc 100644 --- a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc +++ b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc @@ -93,19 +93,40 @@ grails: art: display: true # Whether to display the banner art (default: true). file: 'banner.txt' # Path to a custom banner file on the classpath (default: null). + # The colour to show the art in, where the terminal colours output at all (default: 214). + # A number selects one of the 256 colours a terminal offers; a name selects one of the + # eight it has always had; 'none' leaves the art uncoloured. A value that is neither + # falls back to the default. Named colours are: + # black, red, green, yellow, blue, magenta, cyan, white + # bright_black, bright_red, bright_green, bright_yellow, + # bright_blue, bright_magenta, bright_cyan, bright_white + color: 214 + mark: + # Says how the application was started, centred under the art and above the versions: + # NATIVE for a GraalVM image, AOT CACHE for a JVM given a cache to read with + # -XX:AOTCache, AOT for one running bean definitions that were generated ahead of time. + # An ordinary start says nothing at all. + display: true # (default: true) + # Its own colour, brighter than the art, so the mark is not read as the last line of the + # drawing above it. Same values as grails.banner.art.color (default: 226, bright yellow). + color: 226 + text: '' # Say this instead of what was detected; empty shows nothing. versions: display: true # Whether to display version information (default: true). include: - # Include optional versions (default: []). - # These will trigger class loading to determine the version - # and show 'unknown' if the class is not found. + # Include an optional version (default: []). A library that is not on the classpath, or + # that records no version, is left out rather than shown as unknown -- so including one + # costs nothing where it does not apply. + # The container being served on is already shown by default, under 'container'. These name + # a particular one instead, for an application that wants to be told about it whether or + # not it is the one serving. # Valid options are: - - spring-security - tomcat - jetty - undertow exclude: - # Exclude any of the versions included by default (default: []). + # Exclude any of the versions shown by default (default: []). + # Use 'container' to leave out the servlet container, whichever one it is. # Valid options are: - app - grails @@ -113,6 +134,8 @@ grails: - jvm - spring-boot - spring + - spring-security + - container order: # Optional ordering in which to display the versions in the banner. # You can specify any number of the options listed below @@ -125,6 +148,7 @@ grails: - spring-boot - spring - spring-security + - container - tomcat - jetty - undertow diff --git a/grails-doc/src/en/guide/deployment/deploymentAot.adoc b/grails-doc/src/en/guide/deployment/deploymentAot.adoc new file mode 100644 index 00000000000..ac4b62c4c60 --- /dev/null +++ b/grails-doc/src/en/guide/deployment/deploymentAot.adoc @@ -0,0 +1,172 @@ +//// +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + +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. +//// + +Spring's ahead-of-time (AOT) processing moves bean wiring from startup to build time. Instead of scanning the classpath, reading annotations and evaluating conditions on every boot, the build generates Java source that registers the bean definitions directly, and the application runs that. + +Two things follow from it: startup does less work, and the application becomes eligible for a GraalVM native image, which cannot perform the classpath scanning and reflection that normal startup relies on. + +AOT is opt-in. + + +=== Enabling AOT + + +Apply the Spring Boot AOT plugin alongside the Grails plugins: + +[source,groovy] +---- +apply plugin: 'org.springframework.boot.aot' +---- + +This adds a `processAot` task, which `bootJar` then runs as part of the build. Nothing about a normal `bootRun` or `bootJar` changes until the application is started with AOT enabled: + +[source,bash] +---- +java -Dspring.aot.enabled=true -jar build/libs/myapp.jar +---- + +An application started this way logs `Starting AOT-processed Application` rather than `Starting Application`. + + +=== Generate in production mode + + +`processAot` builds the bean definitions that the packaged application will use, so it must run with the same environment those definitions are meant for: + +[source,groovy] +---- +tasks.named('processAot') { + systemProperty 'grails.env', 'production' +} +---- + +Without this the task runs in the development environment, where reloading is enabled and several beans take a different shape. The URL mappings holder, for example, becomes a proxy whose type cannot be determined without creating it, and generation fails with an error naming a bean that is unrelated to anything in the application: + +[source] +---- +Field urlMappings in org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer +required a bean of type 'grails.web.mapping.UrlMappings' that could not be found. +---- + +Reload-mode beans have no meaning in a packaged artifact, so generating in production mode is correct as well as necessary. + + +=== What AOT changes at runtime + + +An AOT-processed application registers the same beans as a normal one. The difference is the annotation-processing infrastructure it no longer needs, since the work those components do has already been done: + +* `internalConfigurationAnnotationProcessor` +* `internalAutowiredAnnotationProcessor` +* `internalCommonAnnotationProcessor` +* Spring Boot's shared metadata reader factory +* Grails' own configuration class post-processor + +Application beans, plugin beans and auto-configuration beans are all present as usual. + + +=== Limitations + + +Beans contributed from a plugin's `beanRegistrar()` are not generated. Spring marks them as registrar-owned and skips them, and they are registered again at runtime when Grails applies the plugin registrars. They behave correctly, but the plugin scan that produces them still runs on every start, so they do not benefit from generation. + +Writing bean definitions that hold live objects will fail generation. A definition is a recipe rather than an instance, and there is no general way to generate code that reconstructs an arbitrary object, so a constructor argument or property value holding one cannot be processed: + +[source] +---- +UnsupportedTypeValueCodeGenerationException: + Code generation does not support com.example.SomeService +---- + +Reference another bean by name instead, or express the collaborator as a nested bean definition, and the generator can emit both. + +Abstract bean definitions — templates that exist only to be inherited through `bean.parent` — are excluded from generation, because Spring has no representation for them in generated code. Definitions that inherit from one are unaffected: they are generated from their merged definition, with the inherited values already folded in. + +AOT processing on its own does not make an application ready for a native image. A native image additionally requires reachability metadata covering the resources and reflection an application performs at runtime. + +==== Recording reflection for a native image + +Some of that metadata is written for you. The build reads its own output — the compiled artefacts, and the manifest naming each compiled page — and registers them, so a controller, a domain class or a GSP survives into an image without anything having to run. + +What that cannot describe is the framework reflecting on its own behalf while a request is served: a controller method reached through Groovy's dispatch, a conversion asked for while a form is bound. Those are not the application's classes, so nothing in the build output names them. An image built without them starts, serves its home page, and fails on the first request that takes such a path — with `MissingReflectionRegistrationError`, naming a method nobody wrote. + +`traceNativeMetadata` runs the application under GraalVM's tracing agent and records what it actually did: + +[source,groovy] +---- +grails { + nativeMetadata { + paths = ['/', '/login', '/book', '/book/create'] + forms = ['/book/create'] + } +} +---- + +[source,bash] +---- +./gradlew traceNativeMetadata +---- + +The result is merged into `src/native/resources/META-INF/native-image`, alongside the source rather than under `build`, because which paths an image was built to cover is worth reviewing and worth committing. + +`paths` are asked for. `forms` are asked for *and submitted*: the page is read, the fields the form declares are filled in, and they are posted to the action the form names. Submitting matters separately from rendering, because binding a form is a different half of the framework from rendering one — and it is the half that converts. A checkbox arrives as a string and lands on a boolean property, so a form submitted without one never asks the conversion service anything, and the image is built without the answer. + +==== Pages behind a login + +Forms are submitted before paths are asked for, whichever order they appear in the configuration, and the session a form establishes is carried through the rest of the trace. That is what makes a protected page traceable: list its login form, and the pages named in `paths` are reached as a signed-in user. + +[source,groovy] +---- +grails { + nativeMetadata { + forms = ['/login?username=admin&password=secret', '/book/create'] + paths = ['/', '/book', '/book/show/1'] + } +} +---- + +A form may be told what to put in it with a query string, as `/login` is above. Only fields the form actually declares are set from it; a value naming a field that is not there is reported rather than posted, because it was meant for a form that has since changed. + +==== Choosing which form + +A page often carries more than one form — a layout's search box or sign-out button ahead of the page's own. The form declaring the most fields is the one submitted, and the trace output says which it was: + +[source] +---- +POST /book/save -> 200 (4 fields, from /book/create form 2 of 2, the one with the most fields) +---- + +Where the wanted form is not the fullest, name it by its `id`: + +[source,groovy] +---- +forms = ['/book/create#bookForm'] +---- + +==== When the trace fails + +The task fails if what was recorded is not what was asked for: a request that answers with an error or a not-found, a page listed under `forms` that carries no form, a named form that is not on the page, or a form page that answered from somewhere else. + +That last one is the case worth knowing about. A form page reached without whatever makes it reachable answers from the login form instead, and every check after that passes — the page it landed on has a form, its fields are filled in, it posts, and it answers successfully. What would be reported is the login form submitted a second time under the name of the page that was wanted, while the binding the page was listed for went unexercised. So a form that answers from anywhere other than where it was asked for fails the trace, and the message names where it landed. + +Redirects are followed, so an application that sends `/` to its real home page records that page. A request that ends up somewhere other than where it was asked for is reported as a warning naming where it landed — usually the sign that a page needs its login form listed under `forms`. + +NOTE: The agent ships with GraalVM rather than with a JDK, and it must trace the archive the image will be built from — which is compiled for GraalVM's Java. The task uses the project toolchain, which for a project that builds an image is already a GraalVM; `grails.nativeMetadata.javaExecutable` points it elsewhere. + +WARNING: The agent records only what ran. A path that is not listed is not covered, and it will be missing in the way that only appears when somebody uses that page. Treat the list as the coverage it is, and extend it as the application grows. diff --git a/grails-doc/src/en/guide/deployment/deploymentAotCache.adoc b/grails-doc/src/en/guide/deployment/deploymentAotCache.adoc new file mode 100644 index 00000000000..29180c51427 --- /dev/null +++ b/grails-doc/src/en/guide/deployment/deploymentAotCache.adoc @@ -0,0 +1,183 @@ +//// +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + +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. +//// + +Spring's AOT processing removes the work of *deciding* what the beans are. What remains is the work the JVM does regardless: loading and linking classes, and interpreting methods until they are compiled. An AOT cache removes most of that as well. + +The JDK can record a run of an application — the classes it loaded and linked, and the profiles of the methods it executed — into a cache file, and read that file on the next start instead of doing the work again. The cache is a JDK feature rather than a framework one, so it applies to the whole application: the framework, Spring, Hibernate, and the application's own classes alike. + +It requires JDK 25 or later. + +Training is POSIX-only. The cache is written as the training JVM exits normally, so the run has to be asked to stop rather than killed — and a child process on Windows can only be killed. `trainAotCache` says so and stops rather than training a run it could not end. Reading a cache has no such restriction; only producing one does. + + +=== Enabling the cache + + +The cache is produced by running the application, so it is off unless asked for. Say which pages matter: + +[source,groovy] +---- +grails { + aotCache { + enabled = true + paths = ['/', '/login', '/book/index', '/book/show/1'] + } +} +---- + +That adds two tasks. `extractAotCacheApplication` unpacks the executable jar into `build/aot-cache/application`, and `trainAotCache` runs what it unpacked, asks for each path, stops it, and leaves the cache beside the extracted application: + +[source,bash] +---- +./gradlew trainAotCache +---- + +[source] +---- +Trained myapp.aot (167 MB) over 4 paths +---- + +The application is unpacked first because a cache is read against the layout it was trained on. An executable jar loads its dependencies through a nested-jar classloader; the extracted form loads them as ordinary jars on the classpath, and only the second is a layout a cache can be reused against. + + +=== Running with the cache + + +`build/aot-cache` is what is deployed: the extracted application, and beside it the cache and what it was trained from. + +[source] +---- +build/aot-cache/ +├── application/ <1> +├── myapp.aot <2> +└── aot-cache.properties <3> +---- +<1> the extracted application, which is the layout the cache was trained against +<2> the cache +<3> what the cache was trained from + +[source,bash] +---- +cd build/aot-cache/application +java -XX:AOTCache=../myapp.aot -Dspring.aot.enabled=true -Dgrails.env=production -jar myapp.jar +---- + +Run it from the extracted directory, as the training run was run: a cache records the classpath it saw, and reaching the same jar by a different path is a different classpath. + +A JVM given a cache it cannot use does not fail. It reports why, ignores the cache, and starts as it would have anyway — so an invalid cache costs the startup time it was meant to save, silently, and without any other symptom. + + +=== What the training run should do + + +A cache makes the next start fast at whatever the training run did. Training a run that only refreshes the context records the framework starting, and that alone is most of what the startup time is — so most of the benefit is there before any path is named. + +Asking for paths adds a little to that and a good deal more to the first request. Each path asked for during training profiles the code that answers it — URL mapping, the controller, GSP rendering, the data access behind it — so the first real request finds that work done rather than doing it itself. + +So `paths` is worth setting for the pages that matter on a cold start: the ones a load balancer hits first, and the ones a user sees first. An application whose startup time is all that matters can leave it empty and still get most of the benefit. + +A path that answers with an error still profiles the code that produced the error, which is code the application runs too, so nothing here fails the build. The run is a recording, not a test. + +WARNING: The training run is a real run of the application. It executes bootstrap code, connects to whatever the configuration points at, and writes whatever that code writes. Point it at a build-time or throwaway database, never at production. + +It follows that the application has to be able to start in the environment it is trained in, which is production by default — the environment the cache is for. A datasource that only exists in development is not there during training, and the run fails with whatever the application says when it cannot reach its database: + +[source] +---- +The training run ended before it started serving. +What it printed is in build/tmp/trainAotCache/training.log +---- + +The build fails rather than carrying on, because a cache that was never written is indistinguishable at deployment time from one that was. + +Where the defaults are not right, the rest of the run is configurable: + +[source,groovy] +---- +grails { + aotCache { + enabled = true + paths = ['/'] + jvmArguments = ['-Dspring.aot.enabled=true', '-Dgrails.env=production'] + port = 18080 + startTimeoutSeconds = 120 + } +} +---- + +`jvmArguments` is given to the training run *and* has to be given to every run that reads the cache. A cache records what it saw; a run configured differently from the training run is a different application as far as the cache is concerned, and its cache will be declined. + +Because those arguments have to be reproduced at deployment time, they are recorded in `aot-cache.properties`, which ships beside the cache. Keep credentials out of them and pass them to the training run another way. + + +=== Knowing whether a cache still applies + + +A cache is read only by the JDK build that wrote it, against the archive it was trained on, with the arguments it was trained with. Any of those changing invalidates it, and the only symptom is that startup is no longer fast. + +Training runs on the project's Java toolchain where it declares one, so the JDK recorded here is the one the application is compiled for rather than whichever one happened to run Gradle. + +So the training run writes `aot-cache.properties` beside the cache, recording what it was made from: + +[source] +---- +cache.file=myapp.aot +cache.bytes=175019584 +application.archive=myapp-0.1.jar +application.sha256=6f1b... +training.arguments=-Dspring.aot.enabled=true -Dgrails.env=production +training.paths=/ /login /book/index /book/show/1 +java.runtime.version=26.0.2+13 +java.vendor=BellSoft +os.name=Mac OS X +os.arch=aarch64 +---- + +A deployment can compare those values against the JVM it is about to start and the jar it is about to run, and decide whether it has a cache that applies or one that will be quietly ignored. Because the cache is tied to an exact JDK build, a CI pipeline that produces the cache must publish it alongside the JDK it was produced with, and a base image upgrade invalidates every cache built against the previous one. + + +=== What it is worth + + +Measured on a Grails application with GORM for Hibernate, security and asset pipeline, best of three starts on the same machine and JDK: + +[cols="3,1,2"] +|=== +| Start | Startup | First hit of four cold paths + +| Ordinary +| 2.594s +| + +| Spring AOT +| 2.284s +| + +| Spring AOT with a cache trained on refresh only +| 1.015s +| 0.495s + +| Spring AOT with a cache trained over those paths +| 0.933s +| 0.332s +|=== + +The shape of that is the point. Spring's AOT processing on its own moves less than might be expected, because deciding what the beans are was never most of the cost — loading and linking the classes was, and that is what the cache removes, cutting startup by more than half. Naming the paths takes a further tenth off startup and a third off the first requests. + +An AOT cache and a native image solve the same problem differently and are not combined: a native image has no cache to read because it has already done all of this at build time. The cache is what an application gets when it stays on the JVM — including applications a native image cannot yet build, which is most of those using Hibernate. diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index aa8b28f1b17..3481d2f719a 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -277,3 +277,63 @@ be enabled (`grails.mongodb.transactional = true`). Spring Data repositories are session are shared, and the two object-mapping models stay separate. See the link:{mongodb5Guide}index.html#springDataInterop[Spring Data MongoDB Interoperability] section of the GORM for MongoDB guide for details. + +==== Ahead-of-Time Processing + +A Grails application can now be processed by Spring's ahead-of-time engine. Applying Spring Boot's AOT plugin adds a +`processAot` task, which generates the bean definitions as Java source at build time instead of deriving them from +classpath scanning and annotations on every start: + +[source,groovy] +.build.gradle +---- +apply plugin: 'org.springframework.boot.aot' + +tasks.named('processAot') { + systemProperty 'grails.env', 'production' +} +---- + +[source,bash] +---- +java -Dspring.aot.enabled=true -jar build/libs/myapp.jar +---- + +AOT processing is also what a GraalVM native image requires: Spring Boot refuses to start an image without a +build-time generated initializer. Reachability metadata for the application's own artefacts and compiled pages is +written from the build output, and `traceNativeMetadata` records what the framework reflects on along a request path. + +See the xref:deployment/deploymentAot[Ahead-of-Time Processing] section for what it changes at runtime and what it +does not yet cover. + +==== Ahead-of-Time Caching + +On JDK 25 or later, `grails.aotCache` trains a JDK AOT cache by running the packaged application once and recording +the classes it loaded and linked and the methods it ran, so the next start reads that work rather than repeating it: + +[source,groovy] +.build.gradle +---- +grails { + aotCache { + enabled = true + paths = ['/', '/login', '/book/index'] + } +} +---- + +See the xref:deployment/deploymentAotCache[Ahead-of-Time Caching] section, which includes measured figures and the +conditions under which a JVM will decline a cache. + +==== Banner Colour, Container Version and Start Mark + +The startup banner is coloured, reports the servlet container it is running on, and says how the application was +started where that is worth saying — `NATIVE` for an image, `AOT CACHE` for a JVM given a cache to read, `AOT` for one +running generated bean definitions. An ordinary start says nothing. + +Spring Security and the servlet container are also shown by default now. What allows that is a smaller change +underneath: a version that cannot be determined is left out of the banner rather than shown as `unknown`, so an +application without Spring Security says nothing about it instead of saying it does not know. See +xref:conf/applicationClass/customizing[Customizing the Application Class] for the full set of banner options, and the +xref:upgrading#upgrading80x[upgrade guide] for how to restore the previous output. + diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml index b0e4a5c3ff4..182fca6ce06 100644 --- a/grails-doc/src/en/guide/toc.yml +++ b/grails-doc/src/en/guide/toc.yml @@ -393,5 +393,7 @@ deployment: deploymentStandalone: Standalone deploymentContainer: Container Deployment (e.g. Tomcat) deploymentTasks: Deployment Configuration Tasks + deploymentAot: Ahead-of-Time Processing + deploymentAotCache: Ahead-of-Time Caching contributing: title: Contributing to Grails diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2d777e80ccb..ab95bfefd4d 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2255,3 +2255,40 @@ resolved unambiguously before can become ambiguous. Removing the packages from `grails.spring.bean.packages`, or the stray classes from those packages, restores the previous set of beans. + +==== 45. Startup Banner Shows the Servlet Container and Spring Security + +The banner now reports the servlet container it is running on, and the version of Spring Security where the +application has it, without being asked for either. Both were previously opt-in through +`grails.banner.versions.include`. + +What made them opt-in was that a version which could not be determined was shown as `unknown`, so turning one +on by default would have printed `Spring Security: unknown` for every application without it. A version that +cannot be determined is now left out of the banner entirely, which is what allows the two to be shown by +default — and which also means a version explicitly asked for, but not resolvable, no longer appears as +`unknown`. It appears not at all. + +The container is named as itself: an application on Tomcat shows `Tomcat`, one on Jetty shows `Jetty`. It is +configured under the single key `container`, whichever container that turns out to be. + +To restore the previous banner, exclude them: + +[source, yaml] +.grails-app/conf/application.yml +---- +grails: + banner: + versions: + exclude: + - container + - spring-security +---- + +An application that already asked for `spring-security` under `grails.banner.versions.include` keeps working +and needs no change — the option is still recognised, and the version is shown either way. The same is true +of `tomcat`, `jetty` and `undertow`, which remain available for an application that wants to be told about a +particular container whether or not it is the one serving; naming one as well as taking the default shows it +once, not twice. + +A version option that names none of the known keys is now reported as a warning after the banner is printed, +rather than being dropped in silence. diff --git a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp index 97362a9246a..add9c633cc9 100644 --- a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp +++ b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp @@ -2,6 +2,7 @@ <%@ page import="org.springframework.boot.SpringBootVersion"%> <%@ page import="org.springframework.core.SpringVersion"%> <%@ page import="org.springframework.util.ClassUtils"%> +<%@ page import="org.springframework.util.ReflectionUtils"%> ${SpringVersion.getVersion()} - <%-- Spring Security: only when the dependency is present --%> + <%-- Spring Security: only when the dependency is present. The call goes + through ReflectionUtils because invoking Method.invoke from Groovy + resolves to a caller-sensitive overload that a native image rejects. --%> + + value="${springSecurityCoreVersionClass ? ReflectionUtils.invokeMethod(ReflectionUtils.findMethod(springSecurityCoreVersionClass, 'getVersion'), null) : null}"/>
  • diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle index 198315c2fc6..0e735ef39e0 100644 --- a/grails-gradle/plugins/build.gradle +++ b/grails-gradle/plugins/build.gradle @@ -189,3 +189,10 @@ apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') } + +// A variable that is present and empty, which is what a Gradle daemon keeps after a build that set +// one has finished. TrainAotCacheTask drops those rather than passing them to the training run, and +// there is no way to make one from inside a test: an environment is inherited, not written. +tasks.named('test') { + environment 'GRAILS_TRAINING_LEFTOVER', '' +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/AotCacheExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/AotCacheExtension.groovy new file mode 100644 index 00000000000..c35a271a99e --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/AotCacheExtension.groovy @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import groovy.transform.CompileStatic +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * What an application says about the cache the JDK writes for it. + * + *

    A cache makes the next start fast at whatever the training run did, so the only thing an + * application really has to say is which of its pages matter. The rest has an answer that is right + * far more often than not.

    + * + *
    + * grails {
    + *     aotCache {
    + *         enabled = true
    + *         paths = ['/', '/login', '/user/index']
    + *     }
    + * }
    + * 
    + * + * @since 8.0 + */ +@CompileStatic +abstract class AotCacheExtension { + + /** Off unless asked for: training runs the application, which is not part of an ordinary build. */ + abstract Property getEnabled() + + /** + * The paths asked for while training. Empty trains the start alone, which is what + * {@code spring.context.exit=onRefresh} records and is worth having on its own -- but leaves + * every request path to be worked out on the day. + */ + abstract ListProperty getPaths() + + /** + * Given to the training run, and to be given to every run that reads the cache. A cache records + * what it saw, so a run configured differently from the training run reads a cache of a + * different application. + */ + abstract ListProperty getJvmArguments() + + /** Where the training run listens. Not the port the application is deployed on. */ + abstract Property getPort() + + abstract Property getStartTimeoutSeconds() +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy new file mode 100644 index 00000000000..c7bb024106b --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import groovy.json.JsonOutput +import groovy.transform.CompileStatic + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Records the application's own classes so they survive into a native image. + * + *

    Grails reaches an application's artefacts reflectively -- a controller's actions, a domain + * class's properties, a tag library's methods -- and a precompiled page is looked up by the name + * recorded for its view. A native image keeps only the members something asks for, so without this + * the classes are present but unusable.

    + * + *

    The answer is already in the build output: the compiled classes are on disk and the pages are + * listed in the manifest the GSP compiler writes. Reading them here means an application does not + * have to run under the tracing agent to be buildable, which matters because the agent records only + * the paths that were exercised -- a page never visited during the trace is a page missing from the + * image.

    + * + * @since 8.0 + */ +@CacheableTask +@CompileStatic +abstract class GenerateNativeMetadataTask extends DefaultTask { + + /** Where the generated metadata is written within the artifact. */ + static final String METADATA_PATH = 'META-INF/native-image/grails/reachability-metadata.json' + + /** The manifest the GSP compiler writes, mapping a view to the class it compiled to. */ + static final String VIEWS_MANIFEST = 'gsp/views.properties' + + /** Compiled application classes. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getClassesDirs() + + /** Compiled pages, including the manifest naming them. */ + @InputFiles + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getPageClassesDirs() + + /** + * The classpath whose artifacts may carry their own pages. A plugin ships compiled pages and the + * manifest naming them, and an application renders those as readily as its own. + */ + @InputFiles + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getPageClasspath() + + @OutputDirectory + abstract DirectoryProperty getOutputDirectory() + + @TaskAction + void generate() { + Set types = [] as Set + types.addAll(applicationClasses()) + types.addAll(pageClasses()) + types.addAll(pageClassesFromClasspath()) + + List> reflection = types.sort().collect { String type -> + [ + type: type, + allDeclaredMethods: true, + allDeclaredFields: true, + allDeclaredConstructors: true + ] as Map + } + + File target = new File(outputDirectory.get().asFile, METADATA_PATH) + target.parentFile.mkdirs() + target.text = JsonOutput.prettyPrint(JsonOutput.toJson([reflection: reflection])) + logger.info("Recorded ${reflection.size()} application types for reflection") + } + + /** The pages a plugin contributes, read from the manifest each of its artifacts carries. */ + private Set pageClassesFromClasspath() { + Set found = [] as Set + for (File entry : pageClasspath.files) { + if (!entry.isFile() || !entry.name.endsWith('.jar')) { + continue + } + try { + new java.util.jar.JarFile(entry).withCloseable { java.util.jar.JarFile jar -> + java.util.jar.JarEntry manifest = jar.getJarEntry(VIEWS_MANIFEST) + if (manifest == null) { + return + } + Properties views = new Properties() + jar.getInputStream(manifest).withCloseable { views.load(it) } + for (Object value : views.values()) { + found << value.toString() + } + // the closures a page declares are reached the same way its body is + for (java.util.jar.JarEntry e : jar.entries()) { + String name = e.name + if (name.endsWith('.class') && name.contains('$') && !name.contains('/')) { + String simple = name[0.. applicationClasses() { + Set found = [] as Set + for (File dir : classesDirs.files) { + if (!dir.isDirectory()) { + continue + } + dir.eachFileRecurse { File file -> + if (file.name.endsWith('.class')) { + String relative = dir.toPath().relativize(file.toPath()).toString() + found << relative[0.. pageClasses() { + Set found = [] as Set + for (File dir : pageClassesDirs.files) { + File manifest = new File(dir, VIEWS_MANIFEST) + if (!manifest.isFile()) { + continue + } + Properties views = new Properties() + manifest.withInputStream { views.load(it) } + Set pages = views.values().collect { it.toString() } as Set + found.addAll(pages) + // a page's closures are reached the same way its body is; the directory is walked once + // rather than per page, which matters for an application with many views + dir.eachFileRecurse { File file -> + if (!file.name.endsWith('.class')) { + return + } + String name = file.name[0.. 0 && pages.contains(name.substring(0, nested))) { + found << name + } + } + } + found + } +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/NativeMetadataExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/NativeMetadataExtension.groovy new file mode 100644 index 00000000000..9984411e12f --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/NativeMetadataExtension.groovy @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import groovy.transform.CompileStatic +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * What {@code traceNativeMetadata} asks the application for. + * + *

    Declared rather than discovered. The agent records what ran, so this list is the coverage: a + * path nobody wrote down is a path missing from the image, and it will be missing in the way that + * only shows up when somebody uses it.

    + * + * @since 8.0 + */ +@CompileStatic +abstract class NativeMetadataExtension { + + /** Paths to ask for. */ + abstract ListProperty getPaths() + + /** + * Pages whose form is to be submitted, which is not the same as asking for the page. Rendering a + * form and binding one are different halves of the framework, and only the second one converts + * anything. + */ + abstract ListProperty getForms() + + abstract ListProperty getJvmArguments() + + abstract Property getPort() + + abstract Property getStartTimeoutSeconds() + + /** + * A java from a GraalVM, which is where the agent lives. Left unset, the project's toolchain is + * used -- which is a GraalVM already for a project that builds an image. + */ + abstract Property getJavaExecutable() + + /** Where what was recorded is merged, in the application's sources so it can be reviewed. */ + abstract DirectoryProperty getOutputDirectory() +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy new file mode 100644 index 00000000000..b23b933ae2f --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy @@ -0,0 +1,484 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.concurrent.TimeUnit +import java.util.regex.Matcher +import java.util.regex.Pattern + +import groovy.transform.CompileStatic +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +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.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Runs the application under GraalVM's tracing agent and writes down the reflection it did. + * + *

    {@code GenerateNativeMetadataTask} records an application's own artefacts by reading the build + * output, which needs no run and misses nothing of the application's. What it cannot know is the + * framework's own reflection along a request path -- a controller method reached through Groovy's + * dispatch, a conversion asked for while binding a form -- because none of that is in the + * application's classes. An image built without it starts, serves its home page, and fails on the + * request that first takes such a path.

    + * + *

    The agent records exactly that, and records only what ran. So the paths are declared rather + * than discovered, and what is not listed is not covered:

    + * + *
    + * grails {
    + *     nativeMetadata {
    + *         paths = ['/', '/login', '/book', '/book/create']
    + *         forms = ['/book/create']
    + *     }
    + * }
    + * 
    + * + *

    A form is asked for, read, and submitted with the fields it declares, because posting fewer + * than it declares records less than the application does. A checkbox is a string on the wire and a + * boolean on the domain class, so a form submitted without one never asks the conversion service + * anything -- and an image built from that trace fails the first time someone ticks a box.

    + * + *

    Forms are submitted before paths are asked for, and the session one establishes is carried + * through the rest of the trace. A page behind a login is reached by listing the login form, which + * makes the pages named after it reachable:

    + * + *
    + * grails {
    + *     nativeMetadata {
    + *         forms = ['/login?username=admin&password=secret', '/book/create']
    + *         paths = ['/', '/book']
    + *     }
    + * }
    + * 
    + * + *

    Where a page carries more than one form -- a layout's search or sign-out beside the page's own + * -- the one declaring the most fields is submitted, and which it was is reported. A page whose + * wanted form is not the fullest names it: {@code '/book/create#bookForm'}.

    + * + *

    Written to the application's sources rather than to the build directory: what an image was + * built from should be reviewable, and a trace is only as good as the paths someone thought of.

    + * + * @since 8.0 + */ +@CompileStatic +@DisableCachingByDefault(because = 'Records what a run of the application did, which is not an output of its inputs') +abstract class TraceNativeMetadataTask extends DefaultTask { + + /** How the agent is asked for, and the only way its output can be merged with what is there. */ + private static final String AGENT = 'native-image-agent' + + /** The names the agent library goes by, one of which is beside a GraalVM's java. */ + private static final List AGENT_LIBRARIES = [ + 'libnative-image-agent.dylib', 'libnative-image-agent.so', 'native-image-agent.dll' + ] + + private static final Pattern FORM = Pattern.compile(/(?is)]*>.*?<\/form>/) + private static final Pattern ACTION = Pattern.compile(/(?i)\baction\s*=\s*"([^"]*)"/) + private static final Pattern ID = Pattern.compile(/(?i)\bid\s*=\s*"([^"]+)"/) + private static final Pattern FIELD = Pattern.compile(/(?is)<(?:input|select|textarea)\b[^>]*>/) + private static final Pattern NAME = Pattern.compile(/(?i)\bname\s*=\s*"([^"]+)"/) + private static final Pattern VALUE = Pattern.compile(/(?i)\bvalue\s*=\s*"([^"]*)"/) + private static final Pattern TYPE = Pattern.compile(/(?i)\btype\s*=\s*"([^"]+)"/) + + /** + * Carries the session from one request to the next, which is what makes a form that + * authenticates worth submitting: the pages that follow it are only reachable once it has been. + * + *

    Its own cookie store rather than the JVM's. The store belongs to the client, so nothing is + * left behind in a daemon that outlives the build and two traces at once cannot share one.

    + */ + private HttpClient client + + /** The archive to run, which has to be the one the image will be built from. */ + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + abstract RegularFileProperty getArchiveFile() + + /** + * A java from a GraalVM. The agent ships with GraalVM rather than with a JDK, and an application + * built for an image is compiled for GraalVM's Java -- so the JDK that runs the build is usually + * neither, and running the trace on it fails at load or refuses the class file. + */ + @Input + abstract Property getJavaExecutable() + + @Input + abstract ListProperty getJvmArguments() + + /** Paths to ask for. */ + @Input + abstract ListProperty getPaths() + + /** Pages whose form is to be filled in and submitted. */ + @Input + abstract ListProperty getForms() + + @Input + abstract Property getPort() + + @Input + abstract Property getStartTimeoutSeconds() + + /** + * Where the agent merges what it recorded. Not declared as an output: it is in the application's + * sources, and a directory Gradle believes it owns is a directory Gradle will delete. + */ + @Internal + abstract DirectoryProperty getOutputDirectory() + + @TaskAction + void trace() { + File java = new File(javaExecutable.get()) + File metadata = outputDirectory.get().asFile + metadata.mkdirs() + refuseWithoutAgent(java) + + List command = [] + command << java.absolutePath + command << "-agentlib:${AGENT}=config-merge-dir=${metadata.absolutePath}".toString() + command.addAll(jvmArguments.get()) + command << '-jar' << archiveFile.get().asFile.absolutePath + command << "--server.port=${port.get()}".toString() + + File output = new File(temporaryDir, 'trace.log') + Process process = new ProcessBuilder(command) + .directory(archiveFile.get().asFile.parentFile) + .redirectErrorStream(true) + .redirectOutput(output) + .start() + + client = HttpClient.newBuilder() + .cookieHandler(new CookieManager()) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build() + + List answered = [] + try { + awaitStarted(process, output) + // Forms first, then paths. A form that authenticates is what makes the pages after it + // reachable at all: asked for beforehand, a protected page answers with a redirect to + // the login form, and what the agent records is the login page under the name of the + // page that was wanted. The session the form establishes is carried by this client. + forms.get().each { String path -> answered << submit(path) } + paths.get().each { String path -> answered << ask(path) } + } + finally { + stop(process) + } + + answered.each { Answer answer -> logger.lifecycle(' {}', answer.line) } + answered.findAll { Answer answer -> answer.elsewhere }.each { Answer answer -> + logger.warn('{} was answered somewhere other than where it was asked for, so what was ' + + 'recorded is that page and not this one. A page behind a login is reached by ' + + 'listing its form in nativeMetadata.forms.', answer.line) + } + + List failed = answered.findAll { Answer answer -> !answer.recorded } + if (failed) { + throw new GradleException('The application did not answer with the page while being ' + + 'traced, so what was recorded is not what was asked for:\n ' + + "${failed*.line.join('\n ')}\nWhat it printed is in ${output}") + } + logger.lifecycle('Traced {} forms and {} paths into {}', + forms.get().size(), paths.get().size(), metadata) + } + + /** What one request recorded, and whether it recorded the thing it was asked for. */ + private static class Answer { + + final String line + + /** Whether the page asked for is the page the agent saw. */ + final boolean recorded + + /** Whether the answer came from somewhere other than the path asked for. */ + final boolean elsewhere + + Answer(String line, boolean recorded, boolean elsewhere = false) { + this.line = line + this.recorded = recorded + this.elsewhere = elsewhere + } + } + + /** + * Refuses before starting rather than after. Without the agent the JVM stops at load with a + * message about a library path, which reads as a broken machine rather than as the wrong JDK. + */ + private static void refuseWithoutAgent(File java) { + File home = java.parentFile?.parentFile + boolean present = home != null && AGENT_LIBRARIES.any { String library -> + new File(home, "lib/${library}").isFile() + } + if (!present) { + throw new GradleException("${java} has no ${AGENT}, which ships with GraalVM rather than " + + 'with a JDK. Point the project toolchain at a GraalVM, or set ' + + 'grails.nativeMetadata.javaExecutable at one.') + } + } + + /** + * Asks for a path, reading the body so that rendering it is part of what was recorded. + * + *

    Where the answer came from is reported as well as what it was. A redirect is followed, so + * an application that sends {@code /} to its real home page records that page -- but a page + * that answers from somewhere else because the request was not allowed to reach it records the + * wrong thing under the right name, and only saying where it landed tells the two apart.

    + */ + private Answer ask(String path) { + HttpResponse response = client.send( + requestTo(path).GET().build(), HttpResponse.BodyHandlers.ofString()) + String asked = uriOf(path).path + String landed = response.uri().path + boolean elsewhere = landed != asked + String where = elsewhere ? " (landed on ${landed})" : '' + new Answer("GET ${path} -> ${response.statusCode()}${where}".toString(), + response.statusCode() < 400, elsewhere) + } + + /** + * Fills in the form on a page and submits it to wherever the form says. + * + *

    The fields come from the page rather than from a list here, because a list here is a list + * to keep up to date, and the one thing a trace must not do is submit less than the form does.

    + */ + private Answer submit(String declared) { + // A form may say what to put in it, and which of them it is: + // /login?username=admin&password=...#loginForm + // Most need neither. A generated value beats one to keep up to date -- but a form that + // authenticates is only worth submitting with credentials that work, and what it does on + // success is the half worth recording. + int fragment = declared.indexOf('#') + String named = fragment < 0 ? null : declared.substring(fragment + 1) + String withoutId = fragment < 0 ? declared : declared.substring(0, fragment) + int query = withoutId.indexOf('?') + String path = query < 0 ? withoutId : withoutId.substring(0, query) + Map given = [:] + if (query >= 0) { + for (String pair : withoutId.substring(query + 1).split('&')) { + if (!pair) { + continue + } + int equals = pair.indexOf('=') + String name = URLDecoder.decode(equals < 0 ? pair : pair.substring(0, equals), 'UTF-8') + given[name] = equals < 0 ? '' : URLDecoder.decode(pair.substring(equals + 1), 'UTF-8') + } + } + + HttpResponse page = client.send( + requestTo(path).GET().build(), HttpResponse.BodyHandlers.ofString()) + if (page.statusCode() >= 400) { + return new Answer("FORM ${path} -> ${page.statusCode()} asking for the page".toString(), false) + } + // Where the page came from, not only that one did. A form page reached without whatever + // makes it reachable answers from the login form instead, and every check after this one + // passes: that page has a form, its fields are filled in, it posts, and it answers below + // 400. What would be reported is the login form submitted a second time under the name of + // the page that was wanted, while the binding this was listed for went unexercised. + // + // Failed rather than warned, which is where this differs from asking for a path. A path + // that redirects still traced a page, and an application is free to send one page to + // another; a form that redirects traced a different form, and there is no reading of that + // which is what was asked for. + String landed = page.uri().path + if (landed != uriOf(path).path) { + return new Answer("FORM ${path} -> answered from ${landed}, so the form there is not " + + 'this one. A page behind a login is reached by listing its form first.', false) + } + List markups = formsIn(page.body()) + if (!markups) { + return new Answer("FORM ${path} -> no form on the page".toString(), false) + } + String markup = chooseForm(markups, named) + if (markup == null) { + return new Answer("FORM ${path} -> no form with id '${named}' on the page".toString(), false) + } + Matcher action = ACTION.matcher(markup) + String target = action.find() && action.group(1) ? action.group(1) : path + + Map fields = fieldsOf(markup) + if (!fields) { + return new Answer("FORM ${path} -> the form declares no fields".toString(), false) + } + // What was asked for wins, but only for a field the form has: a value for a field that is + // not there was meant for a form that has changed, and silently posting it says nothing. + given.each { String name, String value -> + if (fields.containsKey(name)) { + fields[name] = value + } + else { + logger.warn('{} has no field named {}, so that value was not sent', path, name) + } + } + + String encoded = fields.collect { String name, String value -> + "${URLEncoder.encode(name, 'UTF-8')}=${URLEncoder.encode(value, 'UTF-8')}" + }.join('&') + + HttpResponse posted = client.send( + requestTo(target) + .header('Content-Type', 'application/x-www-form-urlencoded') + .POST(HttpRequest.BodyPublishers.ofString(encoded)) + .build(), + HttpResponse.BodyHandlers.ofString()) + // Which form was submitted is said out loud. A page can carry more than one -- a layout's + // search or sign-out beside the one that was meant -- and submitting the wrong one records + // nothing of the binding it was listed for while reporting that it did. + String which = describeChoice(markups, markup, named) + new Answer("POST ${target} -> ${posted.statusCode()} " + + "(${fields.size()} fields, from ${path}${which})".toString(), + posted.statusCode() < 400) + } + + /** Every form on the page, in the order they appear. */ + private static List formsIn(String html) { + List markups = [] + Matcher form = FORM.matcher(html ?: '') + while (form.find()) { + markups << form.group() + } + markups + } + + /** + * The form to submit: the one named, or the one declaring the most fields. + * + *

    Named where a page carries more than one and the wanted one is not the fullest. Otherwise + * the fullest is the better guess than the first: a layout's search box or sign-out button + * comes before the page's own form and declares almost nothing, and taking the first meant a + * trace that reported a submission having recorded none of the binding it was listed for.

    + */ + private static String chooseForm(List markups, String named) { + if (named) { + return markups.find { String markup -> + Matcher id = ID.matcher(markup) + id.find() && id.group(1) == named + } + } + markups.max { String markup -> fieldsOf(markup).size() } + } + + /** How the chosen form is reported, said only where there was a choice to make. */ + private static String describeChoice(List markups, String chosen, String named) { + if (markups.size() == 1) { + return '' + } + String position = " form ${markups.indexOf(chosen) + 1} of ${markups.size()}" + named ? "${position} named '${named}'" : "${position}, the one with the most fields" + } + + /** + * What the form would send. A field that carries a value sends it, which is how the token a + * security filter demands is carried; a checkbox sends what a ticked one sends; anything else + * sends something recognisable, so a row in a database says where it came from. + */ + private static Map fieldsOf(String markup) { + Map fields = [:] + Matcher field = FIELD.matcher(markup) + while (field.find()) { + String tag = field.group() + Matcher name = NAME.matcher(tag) + if (!name.find()) { + continue + } + Matcher value = VALUE.matcher(tag) + Matcher type = TYPE.matcher(tag) + String kind = type.find() ? type.group(1).toLowerCase(Locale.ROOT) : 'text' + if (kind in ['submit', 'button', 'reset', 'image', 'file']) { + continue + } + fields[name.group(1)] = value.find() ? value.group(1) + : kind == 'checkbox' || kind == 'radio' ? 'on' + : kind == 'password' ? 'traced-secret' + : "traced-${System.nanoTime()}".toString() + } + fields + } + + private HttpRequest.Builder requestTo(String path) { + HttpRequest.newBuilder(uriOf(path)) + .timeout(Duration.ofSeconds(60)) + .header('Accept', 'text/html,*/*') + } + + private URI uriOf(String path) { + URI.create("http://localhost:${port.get()}${path.startsWith('/') ? path : '/' + path}") + } + + private void awaitStarted(Process process, File output) { + long deadline = System.currentTimeMillis() + Duration.ofSeconds(startTimeoutSeconds.get()).toMillis() + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive()) { + throw new GradleException('The application ended before it started serving. ' + + "What it printed is in ${output}") + } + if (output.isFile() && output.text.contains('Started ')) { + return + } + if (serving()) { + return + } + Thread.sleep(250L) + } + throw new GradleException("The application did not start within ${startTimeoutSeconds.get()}s. " + + "What it printed is in ${output}") + } + + private boolean serving() { + try { + new Socket().withCloseable { Socket socket -> + socket.connect(new InetSocketAddress('localhost', port.get()), 500) + true + } + } + catch (IOException ignored) { + false + } + } + + /** + * Asks the run to stop and waits for it. The agent writes what it recorded as the JVM exits, so + * a run that is killed leaves the metadata as it found it. + */ + private static void stop(Process process) { + if (!process.isAlive()) { + return + } + process.destroy() + if (!process.waitFor(120, TimeUnit.SECONDS)) { + process.destroyForcibly() + throw new GradleException('The application did not stop, so the agent wrote nothing') + } + } +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy new file mode 100644 index 00000000000..90bbd0ed5bb --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy @@ -0,0 +1,376 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.security.MessageDigest +import java.time.Duration + +import groovy.transform.CompileStatic +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Runs the application once so the JDK can write down what starting it needs. + * + *

    The run is the point. A cache records the classes loaded and linked, and the profiles of the + * methods that ran, so the next start reads them rather than working them out again -- which means + * what the next start is fast at is whatever this run did. A run that only refreshes the + * context leaves every request path to be worked out on the day.

    + * + *

    So the application is started, asked for the pages an application is asked for, and then asked + * to stop. It has to stop of its own accord: the cache is written as the JVM exits, and a run that is + * killed writes nothing.

    + * + * @since 8.0 + */ +@CompileStatic +@DisableCachingByDefault(because = 'Runs the application and records what it did, which is not reproducible') +abstract class TrainAotCacheTask extends DefaultTask { + + /** + * The extracted application: the cache is only usable against the layout it was trained on. + * + *

    Compared by what is in it and where each file sits within it, not by where the directory + * itself is. Without saying so, Gradle takes the absolute path to be part of the input and + * refuses to validate the task at all -- and a build that did run would key its result to a + * path, so the same application checked out somewhere else, or built on CI, would agree about + * everything and still share nothing.

    + */ + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + abstract DirectoryProperty getApplicationDirectory() + + @Input + abstract Property getArchiveFileName() + + @OutputFile + abstract RegularFileProperty getCacheFile() + + @Input + abstract Property getJavaExecutable() + + /** The version of the JDK above, which is the JDK the cache will only ever be readable by. */ + @Input + abstract Property getJavaVersion() + + @Input + abstract Property getJavaVendor() + + /** Given to the training run, and to be given to every run that reads the cache. */ + @Input + abstract ListProperty getJvmArguments() + + /** The paths to ask for, so their methods are profiled rather than met for the first time later. */ + @Input + abstract ListProperty getPaths() + + @Input + abstract Property getPort() + + @Input + abstract Property getStartTimeoutSeconds() + + /** Written beside the cache, so what the cache was made from can be checked before it is used. */ + @OutputFile + abstract RegularFileProperty getMetadataFile() + + /** The first JDK that can write one. Before it, {@code -XX:AOTCacheOutput} is not an option. */ + private static final int MINIMUM_JAVA_VERSION = 25 + + @TaskAction + void train() { + refuseWhereTheRunCannotBeAskedToStop() + refuseWhereTheJdkCannotWriteACache() + File directory = applicationDirectory.get().asFile + File cache = cacheFile.get().asFile + cache.delete() + + List command = [] + command << javaExecutable.get() + command << "-XX:AOTCacheOutput=${cache.absolutePath}".toString() + command.addAll(jvmArguments.get()) + command << '-jar' << archiveFileName.get() + command << "--server.port=${port.get()}".toString() + + File output = new File(temporaryDir, 'training.log') + ProcessBuilder builder = new ProcessBuilder(command) + .directory(directory) + .redirectErrorStream(true) + .redirectOutput(output) + withoutEmptyVariables(builder.environment()) + Process process = builder.start() + try { + awaitStarted(process, output) + exercise() + } + finally { + stop(process) + } + if (!cache.isFile()) { + throw new GradleException("The training run wrote no cache. What it printed is in ${output}") + } + describe(cache, new File(directory, archiveFileName.get())) + logger.lifecycle('Trained {} ({} MB) over {} paths', + cache.name, (cache.length() / (1024 * 1024)) as long, paths.get().size()) + } + + /** + * Drops the variables that are present but empty from what the run will inherit. + * + *

    The run inherits the daemon's environment, and the daemon's is not the one the build was + * started from. A daemon that once ran a build with a variable set keeps the name afterwards + * and empties the value, so a later build started from a shell that never mentioned it hands + * the run {@code SOME_VARIABLE=""} all the same.

    + * + *

    Spring Boot binds an environment variable over the application's own configuration, and + * relaxed binding means {@code GRAILS_MONGODB_URL} is {@code grails.mongodb.url}. So an empty + * leftover replaced a configured value with nothing, and the training run failed on a property + * the application had set correctly -- reporting it against a name nobody had typed, in a + * build that had run cleanly minutes earlier from a different shell.

    + * + *

    An empty variable says nothing that an absent one does not, so it is not passed on. What + * the build was actually given, empty or not, still arrives: this drops only what the daemon + * kept after the build that set it had finished.

    + */ + private static void withoutEmptyVariables(Map environment) { + environment.entrySet().removeIf { Map.Entry variable -> + !variable.value + } + } + + /** + * Refuses to start a run that could not be ended properly, before it is started. + * + *

    The cache is written as the training JVM exits normally, so the run has to be asked to + * stop rather than killed. {@link Process#destroy()} asks on POSIX and kills on Windows, where + * it is {@code TerminateProcess} and no shutdown runs -- so on Windows the run would be + * exercised in full, killed, and leave no cache, and the build would fail at the end saying the + * cache was not written rather than saying why it could not be.

    + */ + private static void refuseWhereTheRunCannotBeAskedToStop() { + String os = System.getProperty('os.name', '') + if (os.toLowerCase(Locale.ROOT).contains('win')) { + throw new GradleException('Training an AOT cache needs the training run to be asked to ' + + 'stop, and on Windows a child process can only be killed -- which writes no ' + + 'cache. Train on Linux or macOS, or set grails.aotCache.enabled to false.') + } + } + + /** + * Refuses on a JDK that has no cache to write, before the run is started. + * + *

    {@code -XX:AOTCacheOutput} arrived in JDK 25. An earlier JDK does not recognise it and + * stops immediately, so the run would be started, fail at once, and be reported as having + * ended before it started serving -- which reads as a broken application rather than as the + * wrong JDK, and sends whoever is reading the build into a log of the application's own + * start-up that never happened.

    + * + *

    Asked of the JDK the training will run on, which is the project's toolchain rather than + * whichever one is running Gradle.

    + */ + private void refuseWhereTheJdkCannotWriteACache() { + String version = javaVersion.get() + Integer major = majorVersionOf(version) + if (major != null && major < MINIMUM_JAVA_VERSION) { + throw new GradleException("Training an AOT cache needs Java ${MINIMUM_JAVA_VERSION} or " + + "later, and the toolchain this would train on is ${version}. Point the project " + + 'toolchain at a newer JDK, or set grails.aotCache.enabled to false.') + } + } + + /** + * The feature version of a runtime version string, or {@code null} where it does not begin with + * one. Unreadable rather than old: a version this cannot parse is left to the run to answer for, + * since refusing over it would fail a build on a JDK that may be perfectly capable. + */ + private static Integer majorVersionOf(String version) { + int digits = 0 + while (digits < version.length() && Character.isDigit(version.charAt(digits))) { + digits++ + } + digits > 0 ? Integer.valueOf(version.substring(0, digits)) : null + } + + /** + * Writes down what the cache was made from. + * + *

    A cache is read only by the JDK build that wrote it, against the archive it was trained on, + * with the arguments it was trained with. A JVM given one made from anything else declines it and + * starts as it would have anyway -- so what is lost is the speed, silently, and this is what + * tells a deployment which of those it has.

    + */ + private void describe(File cache, File archive) { + Properties properties = new Properties() + properties.setProperty('cache.file', cache.name) + properties.setProperty('cache.bytes', String.valueOf(cache.length())) + properties.setProperty('application.archive', archive.name) + properties.setProperty('application.sha256', sha256(archive)) + properties.setProperty('training.arguments', jvmArguments.get().join(' ')) + properties.setProperty('training.paths', paths.get().join(' ')) + properties.setProperty('java.vendor', javaVendor.get()) + properties.setProperty('java.runtime.version', javaVersion.get()) + properties.setProperty('os.name', System.getProperty('os.name', '')) + properties.setProperty('os.arch', System.getProperty('os.arch', '')) + metadataFile.get().asFile.withOutputStream { OutputStream out -> + properties.store(out, 'What this AOT cache was trained from') + } + } + + private static String sha256(File file) { + MessageDigest digest = MessageDigest.getInstance('SHA-256') + file.withInputStream { InputStream input -> + byte[] buffer = new byte[8192] + int read + while ((read = input.read(buffer)) != -1) { + digest.update(buffer, 0, read) + } + } + digest.digest().encodeHex().toString() + } + + /** + * Waits for the application to start serving, and stops waiting if the run ends first -- + * otherwise a run that fails immediately is waited on for the whole timeout. + * + *

    Either the run says it started or its port answers. The message is Spring Boot's and is + * the earlier and clearer of the two, but it is an INFO log an application is free to turn off + * or reword; a port that accepts a connection is the application's own doing and cannot be + * configured away while it is still an application worth training.

    + */ + private void awaitStarted(Process process, File output) { + long deadline = System.currentTimeMillis() + Duration.ofSeconds(startTimeoutSeconds.get()).toMillis() + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive()) { + throw new GradleException('The training run ended before it started serving.' + + whyItEnded(output) + " What it printed is in ${output}") + } + if (output.isFile() && output.text.contains('Started ')) { + return + } + if (serving()) { + return + } + Thread.sleep(250L) + } + throw new GradleException("The training run did not start within ${startTimeoutSeconds.get()}s. " + + "What it printed is in ${output}") + } + + /** + * What the run said was wrong, for the message that reports it ended. + * + *

    Spring Boot prints why it could not start, and the reason is what someone reading a failed + * build needs -- the build said only that the run had ended and named a file, and finding out + * that an environment variable had quietly replaced a configured property meant reading a log + * that scrolls for eighty lines and is mostly a banner and a class list.

    + * + *

    Read defensively: this runs while reporting a failure, and a log that cannot be read is no + * reason to lose the failure that was already being reported.

    + */ + private static String whyItEnded(File output) { + List reason = [] + try { + if (output.isFile()) { + List lines = output.readLines() + int failed = lines.findLastIndexOf { String line -> line.contains('APPLICATION FAILED TO START') } + if (failed >= 0) { + // Description, then the reason under it, without the box drawing or the blank lines. + reason = lines[failed.. line.replaceAll(/\[[0-9;]*m/, '').trim() } + .findAll { String line -> line && !(line ==~ /[*]+/) && line != 'APPLICATION FAILED TO START' } + .take(4) + } + } + } + catch (IOException ignored) { + return '' + } + reason ? "\n\n${reason.join('\n')}\n" : '' + } + + /** Whether anything is accepting connections on the port the run was told to listen on. */ + private boolean serving() { + try { + new Socket().withCloseable { Socket socket -> + socket.connect(new InetSocketAddress('localhost', port.get()), 500) + true + } + } + catch (IOException ignored) { + false + } + } + + /** + * Asks for each path. A path that answers with an error still profiled the code that produced + * the error, which is code an application runs too, so nothing here fails the build: the run is + * a recording, not a test. + * + *

    That covers a path that is not a URI as much as one that answers with a 500. A path + * written without its leading slash makes a URI with no valid authority, and rejecting it here + * would fail a build over a typo in a list whose whole purpose is to make the next start + * quicker.

    + */ + private void exercise() { + for (String path : paths.get()) { + try { + URI uri = URI.create("http://localhost:${port.get()}${path}") + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection() + connection.requestMethod = 'GET' + connection.instanceFollowRedirects = true + connection.connectTimeout = 10_000 + connection.readTimeout = 30_000 + connection.setRequestProperty('Accept', 'text/html,*/*') + int status = connection.responseCode + InputStream body = status >= 400 ? connection.errorStream : connection.inputStream + body?.withCloseable { it.bytes } + logger.info('Trained {} -> {}', path, status) + } + catch (IOException | RuntimeException e) { + logger.info('Could not reach {} while training: {}', path, e.message) + } + } + } + + /** + * Asks the run to stop and waits for it. The cache is written as the JVM exits, so this waits + * for the exit rather than for the port to close. + */ + private void stop(Process process) { + if (!process.isAlive()) { + return + } + process.destroy() + if (!process.waitFor(120, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly() + throw new GradleException('The training run did not stop, so no cache was written') + } + } +} diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy index 805deadf427..59d76e038e3 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy @@ -44,11 +44,15 @@ import org.gradle.api.attributes.Category import org.gradle.api.file.CopySpec import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileCollection +import org.gradle.api.file.Directory import org.gradle.api.file.RegularFile +import org.gradle.api.plugins.BasePlugin +import org.gradle.api.plugins.ExtensionAware import org.gradle.api.plugins.ExtraPropertiesExtension import org.gradle.api.plugins.GroovyPlugin import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Exec import org.gradle.api.tasks.AbstractCopyTask import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.SourceSet @@ -56,6 +60,7 @@ import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.compile.GroovyCompile import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.language.jvm.tasks.ProcessResources import org.gradle.process.JavaForkOptions @@ -65,6 +70,11 @@ import org.grails.gradle.plugin.commands.GrailsCliGradlePlugin import org.grails.gradle.plugin.exploded.ExplodedCompatibilityRule import org.grails.gradle.plugin.exploded.ExplodedDisambiguationRule import org.grails.gradle.plugin.exploded.GrailsExplodedPlugin +import org.grails.gradle.plugin.aot.AotCacheExtension +import org.grails.gradle.plugin.aot.GenerateNativeMetadataTask +import org.grails.gradle.plugin.aot.NativeMetadataExtension +import org.grails.gradle.plugin.aot.TraceNativeMetadataTask +import org.grails.gradle.plugin.aot.TrainAotCacheTask import org.grails.gradle.plugin.model.GrailsClasspathToolingModelBuilder import org.grails.gradle.plugin.run.FindMainClassTask import org.grails.gradle.plugin.util.SourceSets @@ -83,6 +93,22 @@ import javax.inject.Inject @CompileStatic class GrailsGradlePlugin implements Plugin { + private static final String NATIVE_IMAGE_PLUGIN = 'org.graalvm.buildtools.native' + + private static final String SPRING_BOOT_PLUGIN = 'org.springframework.boot' + + private static final String ASSET_COMPILE_TASK = 'assetCompile' + + /** Where an executable jar reads its classpath from, and so where assets have to be to be found. */ + private static final String CLASSPATH_ASSETS_PATH = 'BOOT-INF/classes/assets' + + private static final int TRAINING_PORT = 18080 + + /** Not the training port: a trace and a training run are both a started application. */ + private static final int TRACING_PORT = 18081 + + private static final int TRAINING_START_TIMEOUT_SECONDS = 180 + private static final String CLI_PID_FILE_PROPERTY = 'grails.cli.pid.file' private static final String RUN_APP_PID_FILE_NAME = 'run-app.pid' @@ -132,6 +158,9 @@ class GrailsGradlePlugin implements Plugin { enableNative2Ascii(project, grailsVersion) + configureNativeMetadata(project) + configureAotCache(project) + configureTemplateResources(project) configureAssetCompilation(project) @@ -144,6 +173,10 @@ class GrailsGradlePlugin implements Plugin { configureBootRunPidFile(project) + configureAheadOfTimeProcessing(project) + + configureNativeImage(project) + configureJavaCompatibilityArgs(project) configureGrailsSourceDirs(project) @@ -836,6 +869,39 @@ ${importStatements} it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets') } } + configureAssetsOnTheClasspath(project) + } + + /** + * Packages the compiled assets where an executable jar can read them. + * + *

    The asset pipeline plugin puts them at the root of whatever archive is built, which is + * where a war serves its web content from and is therefore right for a war. An executable jar + * has no web content: it serves assets by reading them off the classpath, and its classpath is + * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war, + * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself + * renders. Adding them under the classpath directory is what makes them found.

    + * + *

    Only for {@code bootJar}. A war already serves them from the root, and putting them on its + * classpath as well would ship the same bytes twice.

    + */ + private void configureAssetsOnTheClasspath(Project project) { + project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) { + // Read after the build script has run, and by the task the pipeline registers rather + // than by the plugin that registers it: the asset pipeline's plugin id has changed + // once already, and the task name has not. + project.afterEvaluate { + Task assetCompile = project.tasks.findByName(ASSET_COMPILE_TASK) + if (assetCompile == null) { + return + } + project.tasks.named('bootJar', AbstractCopyTask).configure { AbstractCopyTask task -> + task.from(assetCompile.outputs.files) { CopySpec spec -> + spec.into(CLASSPATH_ASSETS_PATH) + } + } + } + } } protected void configureForkSettings(Project project, String grailsVersion) { @@ -886,6 +952,93 @@ ${importStatements} configureToolchainForForkTasks(project) } + /** + * Generates the application's bean definitions for the environment it will be run in. + * + *

    Generation reads the definitions to write them out as code, and an application declares + * different ones in different environments -- development declares reloadable beans, which + * cannot be expressed as generated code. Left at the default the definitions written out are + * development's, and an application built from them is not the application that was asked + * for.

    + * + *

    Nothing here runs unless the application applies Spring Boot's AOT plugin, which is what + * asks for generated definitions in the first place.

    + */ + protected void configureAheadOfTimeProcessing(Project project) { + project.pluginManager.withPlugin('org.springframework.boot.aot') { + project.tasks.named('processAot', JavaExec) { JavaExec task -> + task.systemProperty(Environment.KEY, Environment.PRODUCTION.name) + } + } + } + + /** + * What a Grails application needs of a native image that an image cannot work out for itself. + * + *

    Applications resolve calls through invokedynamic here, because the classic call site + * defines a class as it runs and an image has no way to define one. The framework has to be + * built the same way. This is a convention, so an application that has said otherwise keeps + * what it said.

    + * + *

    Nothing here runs unless the application applies GraalVM's plugin, which is what asks for + * an image in the first place. An application that never builds one is untouched.

    + */ + protected void configureNativeImage(Project project) { + project.pluginManager.withPlugin('org.graalvm.buildtools.native') { + GrailsExtension grailsExt = project.extensions.getByType(GrailsExtension) + grailsExt.indy.convention(true) + } + configureNativeMetadataTrace(project) + } + + /** + * Records the reflection a running application does, which is the half of an image's metadata + * that reading the build output cannot supply. + * + *

    {@code generateNativeMetadata} writes down the application's own artefacts without running + * anything. What it cannot see is the framework reflecting along a request path -- a controller + * method reached through Groovy's dispatch, a conversion asked for while binding a form -- and an + * image built without those starts, serves its home page, and fails on the first request that + * needs one.

    + * + *

    Run deliberately rather than as part of a build: it starts the application, and what it + * writes belongs in the sources beside the code, where the next person can see which paths an + * image was built to cover.

    + */ + protected void configureNativeMetadataTrace(Project project) { + NativeMetadataExtension extension = ((ExtensionAware) project.extensions.getByName('grails')) + .extensions.create('nativeMetadata', NativeMetadataExtension) + extension.paths.convention(['/']) + extension.forms.convention([]) + extension.jvmArguments.convention(['-Dgrails.env=production']) + extension.port.convention(TRACING_PORT) + extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS) + extension.outputDirectory.convention(project.layout.projectDirectory + .dir('src/native/resources/META-INF/native-image')) + + project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) { + TaskProvider bootJar = project.tasks.named('bootJar') + Provider launcher = trainingLauncher(project) + + project.tasks.register('traceNativeMetadata', TraceNativeMetadataTask) { TraceNativeMetadataTask task -> + task.group = BasePlugin.BUILD_GROUP + task.description = 'Runs the application under the tracing agent and records the reflection it does' + task.dependsOn(bootJar) + task.archiveFile.set(project.provider { project.layout.projectDirectory.file(archiveOf(bootJar).absolutePath) }) + // The project's toolchain unless told otherwise, which for a project that builds an + // image is the GraalVM the image is built with -- and the agent is only there. + task.javaExecutable.set(extension.javaExecutable + .orElse(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })) + task.jvmArguments.set(extension.jvmArguments) + task.paths.set(extension.paths) + task.forms.set(extension.forms) + task.port.set(extension.port) + task.startTimeoutSeconds.set(extension.startTimeoutSeconds) + task.outputDirectory.set(extension.outputDirectory) + } + } + } + protected void configureBootRunPidFile(Project project) { // Producer side of the run-app PID contract: the forked app writes its PID to 'run-app.pid' // under the Gradle build directory. The CLI stop-app command resolves the PID file @@ -1115,6 +1268,150 @@ ${importStatements} } } + /** + * Wires up the cache the JDK can write for an application, so the next start reads what a + * training run worked out rather than working it out again. + * + *

    Three steps, because the cache is only usable against the layout it was trained on: the + * archive is extracted, the extracted application is run and asked for its pages, and what the + * run recorded is left beside it. An application asks for this with + * {@code grails.aotCache.enabled}, and says which of its pages matter.

    + */ + protected void configureAotCache(Project project) { + AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails')) + .extensions.create('aotCache', AotCacheExtension) + extension.enabled.convention(false) + extension.paths.convention([]) + extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production']) + extension.port.convention(TRAINING_PORT) + extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS) + + project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) { + TaskProvider bootJar = project.tasks.named('bootJar') + Provider application = project.layout.buildDirectory.dir('aot-cache/application') + Provider launcher = trainingLauncher(project) + + TaskProvider extract = project.tasks.register('extractAotCacheApplication', Exec) { Exec task -> + task.group = BasePlugin.BUILD_GROUP + task.description = 'Extracts the application, which is the form the cache is read against' + task.onlyIf { extension.enabled.get() } + task.dependsOn(bootJar) + // Named so the extraction is skipped when the archive it came from has not moved, + // rather than repeated on every run because nothing said what it produced. + task.inputs.file(project.provider { archiveOf(bootJar) }) + task.outputs.dir(application) + task.doFirst { + File destination = application.get().asFile + project.delete(destination) + destination.mkdirs() + // Set here rather than while the build is configured, so the JDK the toolchain + // resolves to is not provisioned by every build that merely reads this project. + task.commandLine(launcher.get().executablePath.asFile.absolutePath, + '-Djarmode=tools', '-jar', archiveOf(bootJar).absolutePath, 'extract', + '--destination', destination.absolutePath) + } + } + + project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task -> + task.group = BasePlugin.BUILD_GROUP + task.description = 'Runs the application once so the JDK can write down what starting it needs' + task.onlyIf { extension.enabled.get() } + task.dependsOn(extract) + task.applicationDirectory.set(application) + task.archiveFileName.set(project.provider { archiveOf(bootJar).name }) + // Beside the extracted application rather than inside it. Inside, the cache and its + // metadata land in the directory this task declares as its input, so writing them + // changes that input and the task can never be up to date -- every build would run + // the application again to record what the last one already recorded. + Provider beside = project.layout.buildDirectory.dir('aot-cache') + task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") }) + task.metadataFile.set(beside.map { Directory dir -> dir.file('aot-cache.properties') }) + task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath }) + // Recorded from the JDK that will run the training, not the one running the build. + // A cache is read only by the JDK build that wrote it, and this is what a deployment + // checks that against -- so naming the wrong one is worse than naming none. + task.javaVersion.set(launcher.map { JavaLauncher java -> java.metadata.jvmVersion }) + task.javaVendor.set(launcher.map { JavaLauncher java -> java.metadata.vendor }) + task.jvmArguments.set(extension.jvmArguments) + task.paths.set(extension.paths) + task.port.set(extension.port) + task.startTimeoutSeconds.set(extension.startTimeoutSeconds) + } + } + } + + /** The archive to extract, read when the task runs rather than while the build is configured. */ + private static File archiveOf(TaskProvider bootJar) { + Provider archive = (Provider) bootJar.get().property('archiveFile') + archive.get().asFile + } + + /** + * The JDK the cache will be trained on, which is the project's toolchain where it declares one. + * + *

    A cache is read only by the JDK build that wrote it, so training has to happen on the JDK + * the application is compiled for rather than whichever one happens to be running Gradle. A + * project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would otherwise + * have trained with 21, where the cache options do not exist -- and the failure it reported was + * that the training run ended before it started serving.

    + * + *

    Where no toolchain is declared this resolves to the JDK running the build, which is also + * the one that compiled the application.

    + */ + private static Provider trainingLauncher(Project project) { + JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService) + JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension) + toolchains.launcherFor(java.toolchain) + } + + /** + * Records the application's own classes and pages so a native image keeps them usable. The build + * output already names both, so an application does not have to be traced to be buildable. + */ + protected void configureNativeMetadata(Project project) { + // Only where an image is actually being built. The metadata is read by nothing else, and + // generating it means reading the compiled classes and the pages compiled into every + // dependency -- so wiring it into processResources unconditionally made a build that only + // wanted to write a resource compile its sources and resolve its whole runtime classpath + // first, which a project that had declared no repositories could not do. + project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) { + configureNativeMetadataTask(project) + } + } + + private void configureNativeMetadataTask(Project project) { + SourceSet sourceSet = SourceSets.findMainSourceSet(project) + + TaskProvider metadataTask = project.tasks.register( + 'generateNativeMetadata', GenerateNativeMetadataTask) { GenerateNativeMetadataTask task -> + task.group = BasePlugin.BUILD_GROUP + task.description = 'Records the application classes and pages a native image must keep' + // The classes directory is written by more than one task, so the dependency has to be + // stated. It is stated against the compilation rather than the classes task, because + // the classes task also runs processResources, which consumes this task's output. + task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName)) + ['compileGroovy', 'copyAstClasses'].each { String name -> + if (project.tasks.findByName(name)) { + task.dependsOn(project.tasks.named(name)) + } + } + task.classesDirs.from(sourceSet.output.classesDirs) + task.pageClassesDirs.from(project.layout.buildDirectory.dir('gsp-classes/main')) + // Leniently: the pages compiled into dependencies are worth finding, but not at the + // price of making every build that writes a resource resolve the whole runtime + // classpath first. A dependency that cannot be resolved contributes no pages rather + // than failing a build that never asked for a native image. + task.pageClasspath.from(project.configurations.named('runtimeClasspath').map { conf -> + conf.incoming.artifactView { view -> view.lenient(true) }.files + }) + task.outputDirectory.set(project.layout.buildDirectory.dir('generated-resources/grails-native')) + } + + project.tasks.named(sourceSet.processResourcesTaskName, ProcessResources).configure { ProcessResources task -> + task.from(metadataTask) + } + } + /** * Enables native2ascii processing of resource bundles **/ diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy new file mode 100644 index 00000000000..3b2e56f7af1 --- /dev/null +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.scaffolding + +import java.util.jar.JarEntry +import java.util.jar.JarFile + +import groovy.text.GStringTemplateEngine +import groovy.transform.CompileStatic +import groovyjarjarasm.asm.AnnotationVisitor +import groovyjarjarasm.asm.ClassReader +import groovyjarjarasm.asm.ClassVisitor +import groovyjarjarasm.asm.Opcodes +import groovyjarjarasm.asm.Type + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Writes the views a scaffolded controller would otherwise generate on its first request. + * + *

    Scaffolding expands a template into GSP source and compiles the result, and until now it did + * both when the view was first asked for. That costs the first request on the JVM, and a native + * image cannot do it at all: defining a class at runtime is exactly what an ahead-of-time image + * gives up. Expanding the templates here instead lets the ordinary GSP compiler precompile the + * result, so at runtime the views are found rather than produced.

    + * + *

    Only naming is substituted -- the templates read {@code className} and {@code propertyName}, + * and defer everything about the domain class to the field tag libraries at render time. That is + * why this needs no GORM, no application context and no loading of application classes: the + * controllers are read with ASM and the domain class name is enough.

    + * + *

    A view the application already declares is never overwritten, which keeps the existing + * precedence: a hand-written {@code grails-app/views} page wins over a scaffolded one.

    + * + * @since 8.0 + */ +@CacheableTask +@CompileStatic +abstract class GenerateScaffoldedViewsTask extends DefaultTask { + + /** Descriptor of the annotation that marks a scaffolded controller. */ + private static final String SCAFFOLD_ANNOTATION = 'Lgrails/plugin/scaffolding/annotation/Scaffold;' + + /** Path within an artifact holding the scaffolding templates. */ + private static final String TEMPLATE_PATH = 'META-INF/templates/scaffolding/' + + /** The views scaffolding knows how to produce. */ + private static final List VIEW_NAMES = ['index', 'create', 'edit', 'show'] + + /** Compiled application classes, searched for scaffolded controllers. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getClassesDirs() + + /** + * The classpath the scaffolding templates are read from. The application's own + * {@code src/main/templates/scaffolding} takes precedence, matching the runtime lookup. + */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getTemplateClasspath() + + /** Application template overrides, normally {@code src/main/templates/scaffolding}. */ + @InputFiles + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getTemplateOverrides() + + /** The application's own views; anything declared here is left alone. */ + @InputFiles + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getApplicationViews() + + /** Where the generated views are written. */ + @OutputDirectory + abstract DirectoryProperty getOutputDirectory() + + @TaskAction + void generate() { + File outputDir = outputDirectory.get().asFile + outputDir.deleteDir() + outputDir.mkdirs() + + Map templates = loadTemplates() + if (templates.isEmpty()) { + logger.info('No scaffolding templates on the classpath; nothing to generate') + return + } + + Set declared = applicationViews.files + int written = 0 + for (Map.Entry controller : findScaffoldedControllers()) { + String propertyName = controller.value + String className = capitalize(propertyName) + for (String viewName : VIEW_NAMES) { + String template = templates.get(viewName) + if (template == null) { + continue + } + // a view the application wrote itself already wins at runtime, so leaving it out + // keeps build-time and runtime resolution agreeing + if (declared.any { it.path.endsWith("views/${controller.key}/${viewName}.gsp".toString()) }) { + logger.info("Skipping ${controller.key}/${viewName}.gsp, the application declares it") + continue + } + File target = new File(outputDir, "${controller.key}/${viewName}.gsp") + target.parentFile.mkdirs() + target.text = expand(template, className, propertyName) + written++ + } + } + logger.info("Generated ${written} scaffolded view(s)") + } + + /** Expands a template the same way the runtime resolver does, with only the naming bound. */ + private String expand(String template, String className, String propertyName) { + StringWriter out = new StringWriter() + new GStringTemplateEngine() + .createTemplate(template) + .make([className: className, propertyName: propertyName]) + .writeTo(out) + out.toString() + } + + /** + * Maps view name to template text, with the application's overrides winning over the templates + * a plugin contributes. + */ + private Map loadTemplates() { + Map templates = [:] + for (File entry : templateClasspath.files) { + if (entry.isDirectory()) { + File dir = new File(entry, TEMPLATE_PATH) + if (dir.isDirectory()) { + dir.eachFileMatch(~/.*\.gsp/) { File f -> templates.putIfAbsent(baseName(f.name), f.text) } + } + } + else if (entry.name.endsWith('.jar') && entry.isFile()) { + new JarFile(entry).withCloseable { JarFile jar -> + for (JarEntry e : jar.entries()) { + if (e.name.startsWith(TEMPLATE_PATH) && e.name.endsWith('.gsp')) { + templates.putIfAbsent(baseName(e.name.substring(TEMPLATE_PATH.length())), + jar.getInputStream(e).getText('UTF-8')) + } + } + } + } + } + for (File override : templateOverrides.files) { + if (override.isFile() && override.name.endsWith('.gsp')) { + templates.put(baseName(override.name), override.text) + } + } + templates + } + + /** Maps view directory name to the domain property name, for every {@code @Scaffold} controller. */ + private Map findScaffoldedControllers() { + Map found = [:] + for (File dir : classesDirs.files) { + if (!dir.isDirectory()) { + continue + } + dir.eachFileRecurse { File f -> + if (!f.name.endsWith('Controller.class')) { + return + } + String domain = readScaffoldDomain(f) + if (domain != null) { + String controllerName = decapitalize(f.name - 'Controller.class') + found.put(controllerName, decapitalize(domain)) + } + } + } + found + } + + /** + * Returns the simple name of the domain class a controller scaffolds, or {@code null} when it is + * not scaffolded. Read with ASM so the application's classes are never loaded, which keeps the + * task independent of the runtime classpath. + */ + private String readScaffoldDomain(File classFile) { + String domain = null + classFile.withInputStream { InputStream input -> + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + if (descriptor != SCAFFOLD_ANNOTATION) { + return null + } + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + void visit(String name, Object value) { + // both @Scaffold(User) and @Scaffold(domain = User) name the domain class + if (value instanceof Type && (name == 'value' || name == 'domain')) { + String candidate = ((Type) value).className.tokenize('.').last() + if (candidate != 'Void') { + domain = candidate + } + } + } + } + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES) + } + domain + } + + private static String baseName(String fileName) { + fileName.endsWith('.gsp') ? fileName[0..Not cacheable. A page is compiled by a forked Groovy, and what comes out depends on which + * Groovy did it -- which this task's inputs do not describe, because {@code AbstractCompile} does + * not track its own classpath: an application building a native image resolves Groovy 6, and one + * training a cache resolves Groovy 5. Cached, the first build's pages were handed to the second, + * which failed at the moment a page was first rendered, with + * {@code BUG! your call tried to do a property set} -- long after the build said it had + * succeeded.

    + * + *

    Which Java did the compiling is described, by {@link #getJavaLauncher()}. Which Groovy is + * not, and this stays uncacheable until it is.

    + * + *

    Compiling them again costs seconds. Getting this wrong costs an afternoon.

    + * * @author David Estes * @since 4.0 */ @CompileStatic -@CacheableTask +@DisableCachingByDefault(because = 'What a forked compiler produces is not described by this task\'s inputs') abstract class GroovyPageForkCompileTask extends AbstractCompile { @Input @@ -98,9 +111,8 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile { * builds -- which shows up as an {@code UnsupportedClassVersionError} at the moment a page is * first rendered, long after the build called itself successful.

    * - *

    Nested rather than internal because this task is cacheable: the Java that did the - * compiling is part of what the result is, and an entry produced by one must not be handed to - * a build asking for another.

    + *

    Nested rather than internal because the Java that did the compiling is part of what the + * result is: pages built by one are not left standing when the build asks for another.

    */ @Nested abstract Property getJavaLauncher() diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy index 1f46366f9be..fa980893baa 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPagePlugin.groovy @@ -22,12 +22,19 @@ import groovy.transform.CompileStatic import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.plugins.BasePlugin +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.util.PatternFilterable +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.CopySpec import org.gradle.api.file.Directory import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileCollection import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.provider.Provider +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.api.provider.ValueSourceSpec import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetOutput import org.gradle.api.tasks.TaskContainer @@ -36,6 +43,7 @@ import org.gradle.api.tasks.bundling.War import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService +import org.grails.gradle.plugin.scaffolding.GenerateScaffoldedViewsTask import org.grails.gradle.plugin.util.SourceSets /** @@ -79,13 +87,65 @@ class GroovyPagePlugin implements Plugin { JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService) Provider launcher = toolchains.launcherFor(javaExtension.toolchain) + // A scaffolded controller has no views of its own, so they are expanded from their + // templates and compiled with the rest. They are staged together rather than compiled + // separately, because a second compilation writes a second gsp/views.properties and the + // archive tasks discard duplicates, losing the views one of them lists. + // + // Only a project that scaffolds pays for this. Staging copies the views, and pointing the + // compilation at the copy would change what every other project compiles for no reason. + Directory appViews = project.layout.projectDirectory.dir('grails-app/views') + boolean scaffolds = scaffoldsAnyController(project).get() + + Directory viewsToCompile = appViews + if (scaffolds) { + Provider stagedViews = project.layout.buildDirectory.dir('generated/views') + def generateScaffoldedViews = tasks.register( + 'generateScaffoldedViews', GenerateScaffoldedViewsTask) { GenerateScaffoldedViewsTask it -> + it.group = BasePlugin.BUILD_GROUP + it.description = 'Expands the views of scaffolded controllers so they can be precompiled' + // the classes directory is written by more than one task, so the dependency is stated + // against the compilation rather than inferred from the directory + it.dependsOn(tasks.named('compileJava')) + ['compileGroovy', 'copyAstClasses'].each { String name -> + if (project.tasks.findByName(name)) { + it.dependsOn(tasks.named(name)) + } + } + it.classesDirs.from(classesDirs) + it.templateClasspath.from(project.configurations.named('compileClasspath')) + it.templateOverrides.from( + project.fileTree(project.layout.projectDirectory.dir('src/main/templates/scaffolding')) + .matching { PatternFilterable p -> p.include('*.gsp') }) + it.applicationViews.from( + project.fileTree(appViews) + .matching { PatternFilterable p -> p.include('**/*.gsp') }) + it.outputDirectory.set(project.layout.buildDirectory.dir('generated/scaffolded-views')) + } + + tasks.register('stageGroovyPages', Sync) { Sync it -> + it.description = 'Collects the application and scaffolded views for GSP compilation' + it.into(stagedViews) + it.from(appViews) + it.from(generateScaffoldedViews) + // the application's own page wins, matching how the view resolvers are ordered + it.duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + viewsToCompile = stagedViews.get() + } + def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) { it.destinationDirectory.set(destDir) it.tmpDirPath = getTmpDirPath(project) - it.source = project.layout.projectDirectory.dir('grails-app/views') + // the setter takes a directory rather than a provider: it has to set both srcDir + // and the SourceTask inputs, and setting srcDir alone compiles nothing + it.source = viewsToCompile it.serverpath.set('/WEB-INF/grails-app/views/') it.classpath = allClasspath it.javaLauncher.convention(launcher) + if (scaffolds) { + it.dependsOn(tasks.named('stageGroovyPages')) + } } def compileWebappGroovyPages = tasks.register('compileWebappGroovyPages', GroovyPageForkCompileTask) { @@ -145,6 +205,46 @@ class GroovyPagePlugin implements Plugin { } } + /** + * Whether any controller in this project is scaffolded, which decides whether the views are + * staged before they are compiled. The answer is needed while the build is being configured, + * before anything has been compiled, so it is read from the sources. + * + *

    Read through a {@link ValueSource} rather than by opening the files here. Gradle re-runs a + * value source on every build and invalidates the configuration cache when its answer changes, + * so a controller that becomes scaffolded rebuilds the graph that generates its views. Read + * directly, the answer would be an undeclared input: settled once, cached, and wrong from then + * on.

    + * + *

    The match is deliberately loose. {@link GenerateScaffoldedViewsTask} reads the real + * annotation from the compiled class, so a false positive here costs a staging copy and a + * generation task that writes nothing -- while a false negative costs a view that is missing + * from the artifact, found by whoever opens that page.

    + */ + protected Provider scaffoldsAnyController(Project project) { + project.providers.of(ScaffoldedControllers) { ValueSourceSpec spec -> + spec.parameters.controllers.from( + project.fileTree(project.layout.projectDirectory.dir('grails-app/controllers')) + .matching { PatternFilterable p -> p.include('**/*.groovy') }) + } + } + + /** Reads the controller sources for the mark of a scaffolded one, as a tracked build input. */ + abstract static class ScaffoldedControllers implements ValueSource { + + interface Parameters extends ValueSourceParameters { + + ConfigurableFileCollection getControllers() + + } + + @Override + Boolean obtain() { + parameters.controllers.files.any { File controller -> controller.text.contains('@Scaffold') } + } + + } + protected FileCollection resolveClassesDirs(SourceSetOutput output, Project project) { output?.classesDirs ?: project.files(project.layout.buildDirectory.dir('classes/main')) } diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy new file mode 100644 index 00000000000..6a26597ecd9 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTaskSpec.groovy @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream + +import groovy.json.JsonSlurper +import spock.lang.Specification +import spock.lang.TempDir + +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder + +class GenerateNativeMetadataTaskSpec extends Specification { + + @TempDir + File projectDir + + private File classesDir + private File pageClassesDir + + void setup() { + classesDir = new File(projectDir, 'classes') + pageClassesDir = new File(projectDir, 'gsp-classes') + classesDir.mkdirs() + pageClassesDir.mkdirs() + } + + private void writeClass(File root, String path) { + File target = new File(root, path + '.class') + target.parentFile.mkdirs() + target.bytes = new byte[]{ (byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE } + } + + /** Writes the manifest the GSP compiler produces, mapping a view to the class it compiled to. */ + private void writeViewsManifest(File root, Map views) { + File manifest = new File(root, GenerateNativeMetadataTask.VIEWS_MANIFEST) + manifest.parentFile.mkdirs() + Properties properties = new Properties() + views.each { String uri, String pageClass -> properties.setProperty(uri, pageClass) } + manifest.withOutputStream { properties.store(it, null) } + } + + /** A jar shaped like the one a plugin publishes, carrying its own pages. */ + private File writePluginJar(String name, Map views) { + File jar = new File(projectDir, name) + Properties properties = new Properties() + views.each { String uri, String pageClass -> properties.setProperty(uri, pageClass) } + new JarOutputStream(jar.newOutputStream()).withCloseable { JarOutputStream out -> + out.putNextEntry(new JarEntry(GenerateNativeMetadataTask.VIEWS_MANIFEST)) + properties.store(out, null) + out.closeEntry() + views.values().each { String pageClass -> + out.putNextEntry(new JarEntry(pageClass + '.class')) + out.write([0xCA, 0xFE, 0xBA, 0xBE] as byte[]) + out.closeEntry() + } + } + jar + } + + private GenerateNativeMetadataTask task(List classpath = []) { + Project project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.tasks.register('generateNativeMetadata', GenerateNativeMetadataTask) { + GenerateNativeMetadataTask it -> + it.classesDirs.from(classesDir) + it.pageClassesDirs.from(pageClassesDir) + it.pageClasspath.from(classpath) + it.outputDirectory.set(new File(projectDir, 'out')) + } + project.tasks.named('generateNativeMetadata', GenerateNativeMetadataTask).get() + } + + private List recordedTypes(GenerateNativeMetadataTask task) { + task.generate() + File file = new File(task.outputDirectory.get().asFile, GenerateNativeMetadataTask.METADATA_PATH) + file.exists() ? new JsonSlurper().parse(file).reflection.collect { it.type } : [] + } + + void 'the application classes are recorded'() { + given: + writeClass(classesDir, 'com/example/UserController') + writeClass(classesDir, 'com/example/User') + + expect: + recordedTypes(task()).containsAll(['com.example.UserController', 'com.example.User']) + } + + void 'the closures an artefact declares are recorded'() { + given: 'Groovy reads doCall reflectively to pick an overload, so these are reached too' + writeClass(classesDir, 'com/example/BootStrap$_closure1') + + expect: + recordedTypes(task()).contains('com.example.BootStrap$_closure1') + } + + void 'a page is recorded under the class its view compiled to'() { + given: + writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp']) + writeClass(pageClassesDir, 'gsp_app_index_gsp') + + expect: + recordedTypes(task()).contains('gsp_app_index_gsp') + } + + void 'the closures a page declares are recorded'() { + given: + writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp']) + writeClass(pageClassesDir, 'gsp_app_index_gsp') + writeClass(pageClassesDir, 'gsp_app_index_gsp$_run_closure1') + + expect: + recordedTypes(task()).contains('gsp_app_index_gsp$_run_closure1') + } + + void 'a page class left behind by an earlier build is not recorded'() { + given: 'the manifest is the record of this build, the directory is not' + writeViewsManifest(pageClassesDir, ['/WEB-INF/grails-app/views/index.gsp': 'gsp_app_index_gsp']) + writeClass(pageClassesDir, 'gsp_app_index_gsp') + writeClass(pageClassesDir, 'gsp_app_removed_gsp') + + expect: + !recordedTypes(task()).contains('gsp_app_removed_gsp') + } + + void 'the pages a plugin contributes are recorded'() { + given: 'an application renders these as readily as its own, and they are not in its build' + File pluginJar = writePluginJar('fields-plugin.jar', + ['/WEB-INF/grails-app/views/_fields/default/_field.gsp': 'gsp_fields_field_gsp']) + + expect: + recordedTypes(task([pluginJar])).contains('gsp_fields_field_gsp') + } + + void 'an artifact carrying no pages is passed over'() { + given: + File plainJar = new File(projectDir, 'plain.jar') + new JarOutputStream(plainJar.newOutputStream()).withCloseable { JarOutputStream out -> + out.putNextEntry(new JarEntry('com/example/Plain.class')) + out.write([0xCA, 0xFE, 0xBA, 0xBE] as byte[]) + out.closeEntry() + } + writeClass(classesDir, 'com/example/UserController') + + when: + def types = recordedTypes(task([plainJar])) + + then: + noExceptionThrown() + types == ['com.example.UserController'] + } + + void 'every recorded type is registered for the access Grails makes of it'() { + given: + writeClass(classesDir, 'com/example/UserController') + + when: + def task = task() + task.generate() + def entries = new JsonSlurper() + .parse(new File(task.outputDirectory.get().asFile, GenerateNativeMetadataTask.METADATA_PATH)) + .reflection + + then: 'an action is invoked by name, and a domain class has its properties read' + entries.every { it.allDeclaredMethods && it.allDeclaredFields && it.allDeclaredConstructors } + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy new file mode 100644 index 00000000000..bcda3058af7 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTaskSpec.groovy @@ -0,0 +1,345 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.net.CookieHandler +import java.net.CookieManager + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Covers the run that records what an image will need. + * + *

    The agent itself is GraalVM's and is not exercised here: what is worth covering is what the + * task asks the application for, because the metadata is only ever as complete as that.

    + */ +class TraceNativeMetadataTaskSpec extends Specification { + + @TempDir + File temporaryFolder + + Project project = ProjectBuilder.builder().build() + + private File requestedFile + private File metadata + + List getRequested() { + requestedFile?.isFile() ? requestedFile.readLines().findAll { it } : [] + } + + /** + * A task whose run really serves. + * + *

    Launched through a script standing in for GraalVM's java: the agent is asked for on the + * command line, and a JDK without one stops at load -- which is the thing the task refuses in + * advance, and is covered separately below.

    + */ + private TraceNativeMetadataTask task(List paths, List forms, String behaviour = 'ok') { + File application = new File(temporaryFolder, 'application') + application.mkdirs() + File archive = new File(application, 'traced.jar') + archive.text = 'stands in for the archive' + requestedFile = new File(temporaryFolder, 'requested.txt') + requestedFile.text = '' + metadata = new File(temporaryFolder, 'metadata') + + // A java with the agent beside it, which is what the task looks for before it starts. + File home = new File(temporaryFolder, 'graalvm') + new File(home, 'bin').mkdirs() + new File(home, 'lib').mkdirs() + new File(home, 'lib/libnative-image-agent.dylib').text = 'stands in for the agent' + File script = new File(home, 'bin/java') + script.text = """#!/bin/sh +port="" +for arg in "\$@"; do + case "\$arg" in + --server.port=*) port="\${arg#--server.port=}" ;; + esac +done +exec '${new File(System.getProperty('java.home'), 'bin/java').absolutePath}' \\ + -cp '${System.getProperty('java.class.path')}' \\ + ${TracedRunFixture.name} "\$port" '${requestedFile.absolutePath}' ${behaviour} +""" + script.setExecutable(true) + + TraceNativeMetadataTask task = project.tasks.create("trace${paths.size()}${forms.size()}${behaviour}".replaceAll('-', ''), TraceNativeMetadataTask) + task.archiveFile.set(archive) + task.javaExecutable.set(script.absolutePath) + task.jvmArguments.set([]) + task.paths.set(paths) + task.forms.set(forms) + task.port.set(18077) + task.startTimeoutSeconds.set(60) + task.outputDirectory.set(metadata) + task + } + + void 'every path named is asked for'() { + given: + TraceNativeMetadataTask task = task(['/', '/login'], []) + + when: + task.trace() + + then: 'the agent records what ran, so what is not asked for is not in the image' + requested.contains('GET /') + requested.contains('GET /login') + } + + void 'a form is submitted with the fields it declares'() { + given: + TraceNativeMetadataTask task = task([], ['/create']) + + when: + task.trace() + + then: 'to the action the form names, rather than to the page it was read from' + String posted = requested.find { it.startsWith('POST /save') } + posted != null + + and: 'carrying the value a field already had, which is how a security token is carried' + posted.contains('_csrf=a-token') + + and: 'and what a ticked checkbox sends -- the field whose absence means nothing is converted' + posted.contains('published=on') + + and: 'every other field the form declares' + posted.contains('title=') + posted.contains('secret=') + + and: 'but not the button that submits it' + !posted.contains('go=') + } + + void 'a form can be told what to put in it'() { + given: 'a form that authenticates is only worth submitting with credentials that work' + TraceNativeMetadataTask task = task([], ['/create?title=a-known-title']) + + when: + task.trace() + + then: + String posted = requested.find { it.startsWith('POST /save') } + posted.contains('title=a-known-title') + + and: 'while the rest of the form is still filled in' + posted.contains('_csrf=a-token') + posted.contains('published=on') + } + + void 'a value for a field the form does not have is not sent'() { + given: + TraceNativeMetadataTask task = task([], ['/create?nosuchfield=x']) + + when: + task.trace() + + then: 'rather than posting something the application will ignore and calling it covered' + String posted = requested.find { it.startsWith('POST /save') } + !posted.contains('nosuchfield') + } + + void 'the form with the most fields is the one submitted'() { + given: 'a page carrying a layout search form ahead of its own' + TraceNativeMetadataTask task = task([], ['/create']) + + when: + task.trace() + + then: 'taking the first would report a submission having recorded none of the binding' + requested.any { it.startsWith('POST /save') } + !requested.any { it.startsWith('POST /search') } + } + + void 'a form can be named where the wanted one is not the fullest'() { + given: + TraceNativeMetadataTask task = task([], ['/create#searchForm']) + + when: + task.trace() + + then: + requested.any { it.startsWith('POST /search') } + !requested.any { it.startsWith('POST /save') } + } + + void 'naming a form that is not on the page fails the trace'() { + given: 'rather than falling back to another and reporting that one as covered' + TraceNativeMetadataTask task = task([], ['/create#nosuchform']) + + when: + task.trace() + + then: + GradleException e = thrown() + e.message.contains("no form with id 'nosuchform'") + } + + void 'a page behind a login is reached because the form is submitted first'() { + given: 'the form listed after the path it makes reachable, to show the order is not the list' + TraceNativeMetadataTask task = task(['/protected'], ['/login?username=admin&password=s3cret']) + + when: + task.trace() + + then: 'the credentials given reach the application' + requested.any { it.startsWith('POST /authenticate') && it.contains('username=admin') } + + and: 'and the protected page answers with itself rather than with the login page' + requested.any { it == 'GET /protected' } + !requested.any { it == 'GET /login' && requested.indexOf(it) > requested.findIndexOf { it.startsWith('POST /authenticate') } } + } + + void 'a page answered from somewhere else is reported rather than counted'() { + given: 'asked for without the login form that makes it reachable' + TraceNativeMetadataTask task = task(['/protected'], []) + + when: + task.trace() + + then: 'the redirect is followed, so what the agent recorded is the login page' + requested.any { it == 'GET /login' } + } + + void 'a form page answered from the login page does not count as tracing the form'() { + given: 'only the protected form listed, so nothing has made it reachable' + TraceNativeMetadataTask task = task([], ['/protected-form']) + + when: + task.trace() + + then: 'the page it lands on has a form of its own, so every check after the redirect passes' + GradleException e = thrown() + e.message.contains('/protected-form') + e.message.contains('answered from /login') + + and: 'and the login form it landed on was not submitted under the protected page\'s name' + !requested.any { it.startsWith('POST /authenticate') } + + and: 'nor was the binding it was listed for ever exercised' + !requested.any { it.startsWith('POST /protected-save') } + } + + void 'and is traced once the form that makes it reachable is listed'() { + given: + TraceNativeMetadataTask task = task([], + ['/login?username=admin&password=s3cret', '/protected-form']) + + when: + task.trace() + + then: 'the form on the page that was asked for, rather than the one on the way to it' + requested.any { it.startsWith('POST /protected-save') && it.contains('secretTitle=') } + } + + void 'a form on a page that has none fails the trace'() { + given: + TraceNativeMetadataTask task = task([], ['/']) + + when: + task.trace() + + then: 'listed to be submitted and never submitted is a gap, not a success' + GradleException e = thrown() + e.message.contains('no form on the page') + } + + void 'a path that answers with an error fails the trace'() { + given: 'because what would be recorded is the error page rather than the page' + TraceNativeMetadataTask task = task(['/broken'], []) + + when: + task.trace() + + then: + GradleException e = thrown() + e.message.contains('did not answer with the page') + e.message.contains('/broken') + } + + void 'a form that fails to submit fails the trace'() { + given: + TraceNativeMetadataTask task = task([], ['/create'], 'save-fails') + + when: + task.trace() + + then: 'a save that 500s during tracing records the failure and nothing of the save' + GradleException e = thrown() + e.message.contains('did not answer with the page') + } + + void 'the cookie handler the build had is put back after a trace'() { + given: 'a daemon outlives the build, so what this sets is handed to every task after it' + CookieHandler inherited = new CookieManager() + CookieHandler.default = inherited + TraceNativeMetadataTask task = task(['/'], []) + + when: + task.trace() + + then: + CookieHandler.default.is(inherited) + + cleanup: + CookieHandler.default = null + } + + void 'and after one that failed'() { + given: + CookieHandler inherited = new CookieManager() + CookieHandler.default = inherited + TraceNativeMetadataTask task = task(['/broken'], []) + + when: + task.trace() + + then: + thrown(GradleException) + CookieHandler.default.is(inherited) + + cleanup: + CookieHandler.default = null + } + + void 'a java without the agent is refused before the application is started'() { + given: 'the agent ships with GraalVM, so the JDK running the build is usually not one' + File plain = new File(temporaryFolder, 'plain-jdk/bin/java') + plain.parentFile.mkdirs() + plain.text = '#!/bin/sh\nexit 0\n' + plain.setExecutable(true) + TraceNativeMetadataTask task = task(['/'], []) + task.javaExecutable.set(plain.absolutePath) + + when: + task.trace() + + then: 'named, rather than left as a message about a library path from a JVM that would not load' + GradleException e = thrown() + e.message.contains('native-image-agent') + e.message.contains('GraalVM') + + and: 'and nothing was run' + requested.isEmpty() + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy new file mode 100644 index 00000000000..ae9d71e43dc --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TracedRunFixture.groovy @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.util.concurrent.CountDownLatch + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer + +/** + * An application for the trace to record: it serves forms, guards a page behind a login, and writes + * down what it was asked for. + * + *

    The form is the point. A page can be fetched by anything; only submitting one shows whether the + * fields a form declares are the fields that arrive.

    + * + *

    Two of the shapes here are the ones a trace gets wrong rather than the ones it gets right: a + * page carrying a layout form ahead of its own, and a page that answers with a redirect to the login + * form until the login form has been submitted.

    + */ +class TracedRunFixture { + + /** What a page carrying a layout form ahead of its own looks like. */ + private static final String TWO_FORMS = ''' +
    + +
    +
    + + + + + +
    ''' + + private static final String LOGIN_FORM = ''' +
    + + +
    ''' + + /** A form only a signed-in visitor is shown, which is what makes reaching it worth checking. */ + private static final String PROTECTED_FORM = ''' +
    + +
    ''' + + static void main(String[] args) { + int port = Integer.parseInt(args[0]) + File requested = new File(args[1]) + String behaviour = args.length > 2 ? args[2] : 'ok' + + HttpServer server = HttpServer.create(new InetSocketAddress('localhost', port), 0) + server.createContext('/') { HttpExchange exchange -> + String path = exchange.requestURI.path + String method = exchange.requestMethod + String body = method == 'POST' ? exchange.requestBody.getText('UTF-8') : '' + requested << "${method} ${path}${body ? ' ' + body : ''}\n" + boolean signedIn = exchange.requestHeaders.getFirst('Cookie')?.contains('session=in') + + int status = 200 + String response = 'served' + if (path == '/broken') { + status = 500 + response = 'no' + } + else if (path == '/create') { + response = TWO_FORMS + } + else if (path == '/login') { + response = LOGIN_FORM + } + else if (path == '/authenticate') { + exchange.responseHeaders.add('Set-Cookie', 'session=in; Path=/') + status = 302 + exchange.responseHeaders.add('Location', '/') + response = '' + } + else if (path == '/protected-form') { + // the shape that made a redirected form page count as traced: the page it lands on + // has a form of its own, so everything after the redirect succeeds + if (!signedIn) { + status = 302 + exchange.responseHeaders.add('Location', '/login') + response = '' + } + else { + response = PROTECTED_FORM + } + } + else if (path == '/protected') { + // answered from the login page until the login form has been submitted, which is + // what makes asking for it before submitting that form record the wrong page + if (!signedIn) { + status = 302 + exchange.responseHeaders.add('Location', '/login') + response = '' + } + else { + response = 'the protected page' + } + } + else if (path == '/save' && behaviour == 'save-fails') { + status = 500 + response = 'no' + } + + byte[] bytes = response.bytes + exchange.sendResponseHeaders(status, bytes.length ?: -1) + if (bytes.length) { + exchange.responseBody.withCloseable { it.write(bytes) } + } + else { + exchange.responseBody.close() + } + } + server.start() + println 'Started Application in 0.05 seconds' + System.out.flush() + new CountDownLatch(1).await() + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy new file mode 100644 index 00000000000..35a68516fb6 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTaskSpec.groovy @@ -0,0 +1,337 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Covers the run that writes down what starting the application needs. + * + *

    The cache is written as the training JVM exits, so a run that never started, or one that was + * killed, leaves nothing behind -- and a build that carried on regardless would ship an application + * with no cache and no indication that the speed it was built for is absent.

    + */ +class TrainAotCacheTaskSpec extends Specification { + + @TempDir + File temporaryFolder + + Project project = ProjectBuilder.builder().build() + + /** Where the fixture writes the paths it was asked for, so the spec can read them back. */ + private File requestedFile + + List getRequested() { + requestedFile?.isFile() ? requestedFile.readLines().findAll { it } : [] + } + + private static Properties describedBy(TrainAotCacheTask task) { + Properties properties = new Properties() + task.metadataFile.get().asFile.withInputStream { properties.load(it) } + properties + } + + private static String sha256Of(File file) { + java.security.MessageDigest.getInstance('SHA-256').digest(file.bytes).encodeHex().toString() + } + + /** + * A task whose training run really starts, serves and stops. + * + *

    Launched through a script standing in for the JVM: the run is given + * {@code -XX:AOTCacheOutput}, which a JDK before 25 refuses outright, and a test of this task + * should not also be a test of which JDK the build is running on.

    + */ + private TrainAotCacheTask servingTask(List paths, boolean announcesItself = true) { + File application = new File(temporaryFolder, 'serving') + application.mkdirs() + new File(application, 'served.jar').text = 'stands in for the archive, and is what is digested' + requestedFile = new File(temporaryFolder, 'requested.txt') + requestedFile.text = '' + + File script = new File(temporaryFolder, 'fake-jvm.sh') + script.text = """#!/bin/sh +cache="" +port="" +for arg in "\$@"; do + case "\$arg" in + -XX:AOTCacheOutput=*) cache="\${arg#-XX:AOTCacheOutput=}" ;; + --server.port=*) port="\${arg#--server.port=}" ;; + esac +done +exec '${new File(System.getProperty('java.home'), 'bin/java').absolutePath}' \\ + -cp '${System.getProperty('java.class.path')}' \\ + ${TrainingRunFixture.name} "\$cache" "\$port" '${requestedFile.absolutePath}' ${announcesItself ? 'loud' : 'quiet'} +""" + script.setExecutable(true) + + TrainAotCacheTask task = project.tasks.create('trainServing', TrainAotCacheTask) + task.applicationDirectory.set(application) + task.archiveFileName.set('served.jar') + task.cacheFile.set(new File(application, 'demo.aot')) + task.metadataFile.set(new File(application, 'aot-cache.properties')) + task.javaExecutable.set(script.absolutePath) + task.javaVersion.set('25.0.1+9') + task.javaVendor.set('A Vendor') + task.jvmArguments.set(['-Dspring.aot.enabled=true']) + task.paths.set(paths) + task.port.set(18098) + task.startTimeoutSeconds.set(60) + task + } + + private TrainAotCacheTask task(String archiveName, List arguments) { + File application = new File(temporaryFolder, 'application') + application.mkdirs() + new File(application, archiveName).text = 'not a real archive' + TrainAotCacheTask task = project.tasks.create('trainAotCache', TrainAotCacheTask) + task.applicationDirectory.set(application) + task.archiveFileName.set(archiveName) + task.cacheFile.set(new File(application, 'demo.aot')) + task.metadataFile.set(new File(application, 'aot-cache.properties')) + task.javaExecutable.set(new File(System.getProperty('java.home'), 'bin/java').absolutePath) + task.javaVersion.set('25.0.1+9') + task.javaVendor.set('A Vendor') + task.jvmArguments.set(arguments) + task.paths.set([]) + task.port.set(18099) + task.startTimeoutSeconds.set(5) + task + } + + void 'a variable the daemon kept but emptied is not handed to the run'() { + given: 'a run that writes down the environment it was given, and ends' + File application = new File(temporaryFolder, 'environment') + application.mkdirs() + new File(application, 'reports.jar').text = 'stands in for the archive' + File seen = new File(temporaryFolder, 'seen-environment.txt') + File script = new File(temporaryFolder, 'reporting-jvm.sh') + script.text = """#!/bin/sh +env > '${seen.absolutePath}' +exit 1 +""" + script.setExecutable(true) + TrainAotCacheTask task = project.tasks.create('trainEnvironment', TrainAotCacheTask) + task.applicationDirectory.set(application) + task.archiveFileName.set('reports.jar') + task.cacheFile.set(new File(application, 'demo.aot')) + task.metadataFile.set(new File(application, 'aot-cache.properties')) + task.javaExecutable.set(script.absolutePath) + task.javaVersion.set('25.0.1+9') + task.javaVendor.set('A Vendor') + task.jvmArguments.set([]) + task.paths.set([]) + task.port.set(18096) + task.startTimeoutSeconds.set(5) + + and: 'which this build gives the test worker, because an environment cannot be written' + assert System.getenv().containsKey('GRAILS_TRAINING_LEFTOVER') + assert !System.getenv('GRAILS_TRAINING_LEFTOVER') + + when: + task.train() + + then: + thrown(GradleException) + + and: 'Spring Boot binds an environment variable over the application configuration, so an ' + + 'empty one left behind by a daemon replaces a configured value with nothing' + !seen.readLines().any { String line -> line.startsWith('GRAILS_TRAINING_LEFTOVER=') } + + and: 'while the environment the build was actually given still arrives' + seen.readLines().any { String line -> line.startsWith('PATH=') } + } + + void 'a run that never starts serving fails the build'() { + given: 'an archive that is not one, so the run ends immediately' + TrainAotCacheTask task = task('not-an-archive.jar', []) + + when: + task.train() + + then: 'rather than leaving a build to carry on and ship an application with no cache' + GradleException e = thrown() + e.message.contains('ended before it started serving') + } + + void 'what the run printed is where the failure says it is'() { + given: + TrainAotCacheTask task = task('not-an-archive.jar', []) + + when: + task.train() + + then: + GradleException e = thrown() + new File(e.message.replaceAll(/(?s).*is in /, '').trim()).isFile() + } + + void 'a run that could not start is reported with what it said was wrong'() { + given: 'a run that prints why it could not start, the way Spring Boot does, and ends' + File application = new File(temporaryFolder, 'failing') + application.mkdirs() + new File(application, 'failing.jar').text = 'stands in for the archive' + File script = new File(temporaryFolder, 'failing-jvm.sh') + script.text = '''#!/bin/sh +echo "***************************" +echo "APPLICATION FAILED TO START" +echo "***************************" +echo "" +echo "Description:" +echo "" +echo "Invalid value for configuration property grails.mongodb.url, originating from System Environment Property GRAILS_MONGODB_URL" +exit 1 +''' + script.setExecutable(true) + TrainAotCacheTask task = project.tasks.create('trainFailing', TrainAotCacheTask) + task.applicationDirectory.set(application) + task.archiveFileName.set('failing.jar') + task.cacheFile.set(new File(application, 'demo.aot')) + task.metadataFile.set(new File(application, 'aot-cache.properties')) + task.javaExecutable.set(script.absolutePath) + task.javaVersion.set('25.0.1+9') + task.javaVendor.set('A Vendor') + task.jvmArguments.set([]) + task.paths.set([]) + task.port.set(18097) + task.startTimeoutSeconds.set(5) + + when: + task.train() + + then: 'the reason is in the failure, rather than only a file to go and read' + GradleException e = thrown() + e.message.contains('ended before it started serving') + e.message.contains('grails.mongodb.url') + e.message.contains('GRAILS_MONGODB_URL') + + and: 'without the box it was printed in' + !e.message.contains('****') + } + + void 'a run that starts is exercised and described'() { + given: 'a run that says it started, serves what it is asked for, and stops when asked' + TrainAotCacheTask task = servingTask(['/', '/login', '/missing']) + + when: + task.train() + + then: 'the cache the run left behind is described by what it was made from' + Properties described = describedBy(task) + described.'application.archive' == 'served.jar' + described.'application.sha256' == sha256Of(new File(task.applicationDirectory.get().asFile, 'served.jar')) + described.'training.paths' == '/ /login /missing' + described.'training.arguments' == '-Dspring.aot.enabled=true' + described.'java.runtime.version' == '25.0.1+9' + described.'java.vendor' == 'A Vendor' + + and: 'every path was asked for, including the one that answered with an error' + requested == ['/', '/login', '/missing'] + } + + void 'a path that is not a URI is skipped rather than failing the build'() { + given: 'a leading slash left off, which makes a URI with no valid authority' + TrainAotCacheTask task = servingTask(['login', '/']) + + when: + task.train() + + then: 'a typo in a list that exists to make the next start quicker does not fail a build' + noExceptionThrown() + + and: 'the paths that are URIs were still asked for' + requested == ['/'] + } + + void 'a run that says nothing is found by its port'() { + given: 'an application that has turned off the log line Spring Boot starts with' + TrainAotCacheTask task = servingTask(['/'], false) + + when: + task.train() + + then: 'a message an application may reword or silence is not the only way to know it is up' + noExceptionThrown() + requested == ['/'] + } + + void 'a run is refused where it could not be asked to stop'() { + given: 'Windows, where a child process can only be killed, and a killed run writes no cache' + String os = System.getProperty('os.name') + System.setProperty('os.name', 'Windows 11') + TrainAotCacheTask task = task('not-an-archive.jar', []) + + when: + task.train() + + then: 'said before the run rather than after it, as "no cache was written"' + GradleException e = thrown() + e.message.contains('can only be killed') + + cleanup: + System.setProperty('os.name', os) + } + + void 'a run is refused on a JDK that has no cache to write'() { + given: 'the toolchain the training would run on, which is not the one running the build' + TrainAotCacheTask task = task('not-an-archive.jar', []) + task.javaVersion.set('21.0.12+10') + + when: + task.train() + + then: 'said as the wrong JDK, rather than as an application that ended before it served' + GradleException e = thrown() + e.message.contains('Java 25 or later') + e.message.contains('21.0.12+10') + } + + void 'a JDK that can write one is not refused'() { + given: + TrainAotCacheTask task = task('not-an-archive.jar', []) + task.javaVersion.set(version) + + when: + task.train() + + then: 'it gets as far as running, which is where this spec stops caring' + GradleException e = thrown() + !e.message.contains('Java 25 or later') + + where: + version << ['25.0.1+9', '26', '31.0.2+7'] + } + + void 'a version that cannot be read is left to the run to answer for'() { + given: 'refusing over an unreadable version would fail a build on a capable JDK' + TrainAotCacheTask task = task('not-an-archive.jar', []) + task.javaVersion.set('a vendor string nobody parsed') + + when: + task.train() + + then: + GradleException e = thrown() + !e.message.contains('Java 25 or later') + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy new file mode 100644 index 00000000000..89e7af2a8bb --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/aot/TrainingRunFixture.groovy @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.aot + +import java.util.concurrent.CountDownLatch + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer + +/** + * Stands in for the application a training run starts, so the run can be exercised for real. + * + *

    It does the three things {@link TrainAotCacheTask} depends on and nothing else: it says it + * started the way Spring Boot says it, it answers on the paths it is asked for and writes down + * which, and it writes its cache while shutting down rather than when told to stop -- which is what + * makes a killed run leave nothing behind.

    + * + *

    Run by the script the spec writes, not by {@code java -jar}: a real JVM would have to + * understand {@code -XX:AOTCacheOutput}, which ties the test to the JDK the build happens to run + * on.

    + */ +class TrainingRunFixture { + + static void main(String[] args) { + File cache = new File(args[0]) + int port = Integer.parseInt(args[1]) + File requested = new File(args[2]) + + HttpServer server = HttpServer.create(new InetSocketAddress('localhost', port), 0) + server.createContext('/') { HttpExchange exchange -> + requested << "${exchange.requestURI.path}\n" + byte[] body = 'served'.bytes + int status = exchange.requestURI.path == '/missing' ? 404 : 200 + exchange.sendResponseHeaders(status, body.length) + exchange.responseBody.withCloseable { it.write(body) } + } + server.start() + + Runtime.runtime.addShutdownHook(new Thread({ + cache.text = 'a cache is what a run leaves behind as it goes' + })) + + if (args.length < 4 || args[3] != 'quiet') { + println 'Started Application in 0.05 seconds' + System.out.flush() + } + + new CountDownLatch(1).await() + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy new file mode 100644 index 00000000000..fc1ca20b2d1 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/AssetClasspathPackagingSpec.groovy @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.core + +import org.gradle.testkit.runner.BuildResult + +/** + * Covers compiled assets being readable from an executable jar. + * + *

    The asset pipeline packages them at the root of the archive, which is where a war serves its + * web content from. An executable jar has no web content: assets are read off the classpath, and + * its classpath is {@code BOOT-INF/classes}. The same bytes at the same place, in a jar rather than + * a war, are packaged but unreachable -- the page renders and every asset on it is a 404.

    + */ +class AssetClasspathPackagingSpec extends GradleSpecification { + + void 'compiled assets are packaged where an executable jar reads its classpath'() { + given: + setupTestResourceProject('asset-classpath-packaging') + + when: + BuildResult result = executeTask('inspectAssetPackaging') + + then: + result.output.contains('ON_CLASSPATH=true') + result.output.contains('ON_CLASSPATH_SVG=true') + + and: 'every compiled asset, not a sample of them' + result.output.contains('ASSET_COUNT=2') + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy new file mode 100644 index 00000000000..e95110ef648 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsNativeImageDefaultsSpec.groovy @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.core + +import org.gradle.testkit.runner.BuildResult + +/** + * Covers what an application gets for building a native image, and what it does not get for not + * building one. + * + *

    These are reactions to the plugin the application applied, not a setting it has to find. + * Applying GraalVM's plugin is already how an application says it wants an image; nothing here is + * reached without it, so an application that has no use for any of it pays nothing and sees no + * change.

    + * + * @since 8.0 + * @see GrailsGradlePlugin#configureNativeImage + */ +class GrailsNativeImageDefaultsSpec extends GradleSpecification { + + void 'an application that builds no image is left as it was'() { + given: + setupTestResourceProject('native-defaults-off') + + when: + BuildResult result = executeTask('inspectDefaults') + + then: 'invokedynamic stays off, as it is for every application that did not ask for it' + result.output.contains('INDY=false') + + and: 'and nothing an image would want has been added' + result.output.contains('HAS_NATIVE_EXTENSION=false') + } + + void 'definitions are generated for the environment the application will run in'() { + given: 'the plugin that asks for generated definitions is what this reacts to' + setupTestResourceProject('native-defaults-aot') + + when: + BuildResult result = executeTask('inspectAot') + + then: 'development declares reloadable beans, which cannot be written out as code' + result.output.contains('GRAILS_ENV=production') + } + + void 'an application that builds an image gets what an image needs'() { + given: + setupTestResourceProject('native-defaults-on') + + when: + BuildResult result = executeTask('inspectDefaults') + + then: 'a classic call site defines a class as it runs, which an image has no way to do' + result.output.contains('INDY=true') + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy new file mode 100644 index 00000000000..33ffe0736c5 --- /dev/null +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * 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.grails.gradle.plugin.scaffolding + +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream + +import groovyjarjarasm.asm.AnnotationVisitor +import groovyjarjarasm.asm.ClassWriter +import groovyjarjarasm.asm.Opcodes +import groovyjarjarasm.asm.Type +import spock.lang.Specification +import spock.lang.TempDir + +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder + +class GenerateScaffoldedViewsTaskSpec extends Specification { + + @TempDir + File projectDir + + private File classesDir + private File templateJar + + void setup() { + classesDir = new File(projectDir, 'classes') + classesDir.mkdirs() + templateJar = new File(projectDir, 'templates.jar') + writeTemplateJar(templateJar, [ + index : 'list of ${propertyName} for ${className}', + create: 'create ${className}', + edit : 'edit ${className}', + show : 'show ${className}']) + } + + /** A jar shaped like the one the scaffolding plugin publishes. */ + private void writeTemplateJar(File jar, Map templates) { + new JarOutputStream(jar.newOutputStream()).withCloseable { JarOutputStream out -> + templates.each { String name, String body -> + out.putNextEntry(new JarEntry("META-INF/templates/scaffolding/${name}.gsp")) + out.write(body.bytes) + out.closeEntry() + } + } + } + + /** + * Writes a class carrying {@code @Scaffold}, so the task reads a real annotation rather than a + * stand-in for one. + */ + private void writeController(String controllerName, String domainClassName) { + ClassWriter writer = new ClassWriter(0) + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null, + 'java/lang/Object', null) + AnnotationVisitor annotation = writer.visitAnnotation( + 'Lgrails/plugin/scaffolding/annotation/Scaffold;', true) + annotation.visit('value', Type.getObjectType("com/example/${domainClassName}")) + annotation.visitEnd() + writer.visitEnd() + File target = new File(classesDir, "com/example/${controllerName}.class") + target.parentFile.mkdirs() + target.bytes = writer.toByteArray() + } + + private void writePlainController(String controllerName) { + ClassWriter writer = new ClassWriter(0) + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null, + 'java/lang/Object', null) + writer.visitEnd() + File target = new File(classesDir, "com/example/${controllerName}.class") + target.parentFile.mkdirs() + target.bytes = writer.toByteArray() + } + + private GenerateScaffoldedViewsTask task(List overrides = [], List views = []) { + Project project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.tasks.register('generateScaffoldedViews', GenerateScaffoldedViewsTask) { + GenerateScaffoldedViewsTask it -> + it.classesDirs.from(classesDir) + it.templateClasspath.from(templateJar) + it.templateOverrides.from(overrides) + it.applicationViews.from(views) + it.outputDirectory.set(new File(projectDir, 'out')) + } + project.tasks.named('generateScaffoldedViews', GenerateScaffoldedViewsTask).get() + } + + private File generated(GenerateScaffoldedViewsTask task, String path) { + new File(task.outputDirectory.get().asFile, path) + } + + void 'a scaffolded controller gets the full set of views'() { + given: + writeController('UserController', 'User') + def task = task() + + when: + task.generate() + + then: + ['index', 'create', 'edit', 'show'].every { generated(task, "user/${it}.gsp").exists() } + } + + void 'the naming the templates read is substituted'() { + given: + writeController('UserController', 'User') + def task = task() + + when: + task.generate() + + then: + generated(task, 'user/index.gsp').text == 'list of user for User' + } + + void 'a controller without the annotation is left alone'() { + given: + writePlainController('PlainController') + def task = task() + + when: + task.generate() + + then: + !generated(task, 'plain').exists() + } + + void 'the domain class named by the annotation drives the naming, not the controller'() { + given: 'a controller whose name does not match the domain it scaffolds' + writeController('AccountController', 'Person') + def task = task() + + when: + task.generate() + + then: + generated(task, 'account/index.gsp').text == 'list of person for Person' + } + + void 'an application template overrides the one a plugin contributes'() { + given: + writeController('UserController', 'User') + File overrides = new File(projectDir, 'templates') + overrides.mkdirs() + File custom = new File(overrides, 'index.gsp') + custom.text = 'custom ${className}' + def task = task([custom]) + + when: + task.generate() + + then: + generated(task, 'user/index.gsp').text == 'custom User' + } + + void 'a view the application declares is not generated over'() { + given: 'the application writes its own index page' + writeController('UserController', 'User') + File views = new File(projectDir, 'grails-app/views/user') + views.mkdirs() + File declared = new File(views, 'index.gsp') + declared.text = 'hand written' + def task = task([], [declared]) + + when: + task.generate() + + then: 'the runtime prefers the declared page, so generating one would only shadow it' + !generated(task, 'user/index.gsp').exists() + + and: 'the views it does not declare are still generated' + generated(task, 'user/create.gsp').exists() + } + + void 'a stale view from a previous run does not survive'() { + given: + writeController('UserController', 'User') + def task = task() + task.generate() + File stale = generated(task, 'gone/index.gsp') + stale.parentFile.mkdirs() + stale.text = 'stale' + + when: + task.generate() + + then: + !stale.exists() + } +} diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy index e975429943e..c471523dfe4 100644 --- a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy +++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageToolchainSpec.groovy @@ -98,7 +98,7 @@ class GroovyPageToolchainSpec extends Specification { } void 'the Java that did the compiling is part of what the result is'() { - given: 'the task is cacheable, so an entry built by one Java must not be reused by another' + given: 'pages built by one Java must not be left standing when the build asks for another' Project project = projectWithPages() expect: 'declared as an input, which is what keeps the two apart' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle new file mode 100644 index 00000000000..d6c459512a7 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/build.gradle @@ -0,0 +1,42 @@ +// The asset pipeline plugin is not on the test classpath, and this does not need it: the wiring +// under test reacts to the assetCompile task, so a stand-in that produces compiled assets the same +// way exercises the same path. +plugins { + id 'org.apache.grails.gradle.grails-web' +} + +group = 'org.example.test' + +grails { + bom = null + cliAutoProvision = false +} + +springBoot { + mainClass = 'org.example.test.Application' +} + +def compiledAssets = layout.buildDirectory.dir('assets') + +tasks.register('assetCompile') { + outputs.dir(compiledAssets) + doLast { + def dir = compiledAssets.get().asFile + dir.mkdirs() + new File(dir, 'application-d41d8c.css').text = 'body { }' + new File(dir, 'grails-527fa9.svg').text = '' + } +} + +tasks.register('inspectAssetPackaging') { + dependsOn 'bootJar' + doLast { + def archive = tasks.named('bootJar', Jar).get().archiveFile.get().asFile + def entries = new java.util.zip.ZipFile(archive).withCloseable { zip -> + zip.entries().findAll { !it.directory }.collect { it.name } as Set + } + println "ON_CLASSPATH=${entries.contains('BOOT-INF/classes/assets/application-d41d8c.css')}" + println "ON_CLASSPATH_SVG=${entries.contains('BOOT-INF/classes/assets/grails-527fa9.svg')}" + println "ASSET_COUNT=${entries.count { it.startsWith('BOOT-INF/classes/assets/') }}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties new file mode 100644 index 00000000000..52f4d78c7c2 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/gradle.properties @@ -0,0 +1,2 @@ +grailsVersion=__PROJECT_VERSION__ +org.gradle.jvmargs=-Xmx1g diff --git a/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle new file mode 100644 index 00000000000..922d6e337a1 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/asset-classpath-packaging/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'asset-classpath-packaging' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle new file mode 100644 index 00000000000..f85514c8a29 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' + id 'org.springframework.boot.aot' +} + +tasks.register('inspectAot') { + doLast { + println "GRAILS_ENV=${tasks.named('processAot').get().systemProperties['grails.env']}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/grails-app/conf/application.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle new file mode 100644 index 00000000000..04f5f689a93 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-aot/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'native-defaults-aot' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle new file mode 100644 index 00000000000..aa04269ab4e --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' +} + +// An application that builds neither an image nor a cache applies neither plugin, and so should +// find nothing has been decided on its behalf. +tasks.register('inspectDefaults') { + doLast { + println "INDY=${project.extensions.getByName('grails').indy.get()}" + println "HAS_NATIVE_EXTENSION=${project.extensions.findByName('graalvmNative') != null}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/grails-app/conf/application.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle new file mode 100644 index 00000000000..0a4b1dcc6ac --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-off/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'native-defaults-off' diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle new file mode 100644 index 00000000000..8c7467cf904 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'org.apache.grails.gradle.grails-app' + id 'org.graalvm.buildtools.native' version '1.1.7' +} + +tasks.register('inspectDefaults') { + doLast { + println "INDY=${project.extensions.getByName('grails').indy.get()}" + } +} diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties new file mode 100644 index 00000000000..35c332fb874 --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/gradle.properties @@ -0,0 +1 @@ +grailsVersion=__PROJECT_VERSION__ diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/grails-app/conf/application.yml b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/grails-app/conf/application.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle new file mode 100644 index 00000000000..8012c888a5d --- /dev/null +++ b/grails-gradle/plugins/src/test/resources/test-projects/native-defaults-on/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'native-defaults-on' diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java new file mode 100644 index 00000000000..111bdfc450d --- /dev/null +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gsp.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +import org.grails.gsp.GroovyPageMetaInfo; + +/** + * Registers what a page compiled at build time is read from. + * + *

    Compiling a page splits it: the code becomes a class, and the static text between the code -- + * most of the page -- is written beside it as a resource, along with the line numbers that map the + * generated code back to the page it came from. The class reads that resource as it renders.

    + * + *

    An image carries a resource only when it has been asked to, and nothing asked for these: they + * are named by a convention rather than by any code. The page then rendered with nothing where its + * text should be, and reported a null the page itself could not explain.

    + * + * @since 8.0 + */ +public class PrecompiledPageRuntimeHints implements RuntimeHintsRegistrar { + + /** Where the pages compiled at build time are listed, read to find them at all. */ + private static final String VIEWS = "gsp/views.properties"; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + hints.resources().registerPattern(VIEWS); + hints.resources().registerPattern("*" + GroovyPageMetaInfo.HTML_DATA_POSTFIX); + hints.resources().registerPattern("*" + GroovyPageMetaInfo.LINENUMBERS_DATA_POSTFIX); + } +} diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java index 04528d18247..c99b7d0431b 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/io/DefaultGroovyPageLocator.java @@ -34,6 +34,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.aot.AotDetector; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -410,8 +411,30 @@ protected Resource findResource(List searchPaths) { return foundResource; } + /** + * Whether the pages compiled at build time should be used. + * + *

    They are skipped in development so that editing a page takes effect without a restart, and + * that is decided by whether the application looks like a project on disk. An ahead-of-time + * image can look like one -- it is a single executable that may be run from anywhere, including + * the directory it was built in -- but it cannot compile a page at run time, because it cannot + * define a class at all. Reading the sources there renders nothing, so the compiled pages are + * used whatever the surroundings suggest.

    + */ private boolean isPrecompiledAvailable() { - return precompiledGspMap != null && precompiledGspMap.size() > 0 && !Environment.isDevelopmentMode(); + if (precompiledGspMap == null || precompiledGspMap.isEmpty()) { + return false; + } + return !isDevelopmentMode() || AotDetector.useGeneratedArtifacts(); + } + + /** + * Whether the application is being developed, which is decided by whether it looks like a project + * on disk. Overridable because that is derived from the working directory when the class is + * loaded, and so cannot be varied any other way. + */ + protected boolean isDevelopmentMode() { + return Environment.isDevelopmentMode(); } public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { diff --git a/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..178ec340883 --- /dev/null +++ b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.gsp.aot.PrecompiledPageRuntimeHints diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..9eea0dbe87b --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gsp.aot + +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates +import spock.lang.Specification + +/** + * Covers what a page compiled at build time is read from being carried into an image. + * + *

    Compiling a page splits it: the code becomes a class, and the static text between the code -- + * most of the page -- is written beside it as a resource that the class reads as it renders. An + * image carries a resource only when asked, and these are named by convention rather than by any + * code, so nothing asked. Every page then rendered with nothing where its text should be.

    + */ +class PrecompiledPageRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new PrecompiledPageRuntimeHints().registerHints(hints, getClass().classLoader) + } + + void 'the static text of a compiled page is carried'() { + expect: 'without it the page renders empty and reports a null it cannot explain' + RuntimeHintsPredicates.resource() + .forResource('gsp_demo_indexgsp_html.data') + .test(hints) + } + + void 'the line numbers that map generated code back to the page are carried'() { + expect: + RuntimeHintsPredicates.resource() + .forResource('gsp_demo_indexgsp_linenumbers.data') + .test(hints) + } + + void 'the list of the pages compiled at build time is carried'() { + expect: 'read to find the compiled pages at all' + RuntimeHintsPredicates.resource().forResource('gsp/views.properties').test(hints) + } + + void 'an unrelated resource is not carried'() { + expect: 'the patterns name the compiled pages rather than everything' + !RuntimeHintsPredicates.resource().forResource('application.yml').test(hints) + } +} diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy new file mode 100644 index 00000000000..5c586afd413 --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/io/DefaultGroovyPageLocatorPrecompiledSpec.groovy @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.gsp.io + +import grails.util.Environment + +import org.springframework.aot.AotDetector +import spock.lang.Specification + +/** + * Covers which pages are used when the application looks like a project on disk. + * + *

    Development skips the pages compiled at build time so that editing one takes effect without a + * restart. An ahead-of-time image can look the same -- it is a single executable, and it may be run + * from the directory it was built in -- but it cannot compile a page at run time, so reading the + * sources renders nothing.

    + */ +class DefaultGroovyPageLocatorPrecompiledSpec extends Specification { + + private static final String VIEW = '/views/index.gsp' + + DefaultGroovyPageLocator locator = new DefaultGroovyPageLocator() + + void setup() { + locator.setPrecompiledGspMap([(VIEW): CompiledPage.name]) + } + + private static final String AOT_KEY = 'spring.aot.enabled' + + /** Restores the property, so the environment other specs observe is unchanged. */ + private void withAot(boolean aot, Closure body) { + String previous = System.getProperty(AOT_KEY) + try { + aot ? System.setProperty(AOT_KEY, 'true') : System.clearProperty(AOT_KEY) + body.call() + } + finally { + previous == null ? System.clearProperty(AOT_KEY) : System.setProperty(AOT_KEY, previous) + } + } + + /** A locator that believes it is looking at a project on disk, which the filesystem decides. */ + private DefaultGroovyPageLocator developmentLocator() { + def developing = new DefaultGroovyPageLocator() { + @Override + protected boolean isDevelopmentMode() { true } + } + developing.setPrecompiledGspMap([(VIEW): CompiledPage.name]) + developing + } + + void 'a compiled page is used when the application is not a project on disk'() { + when: + def source = null + withAot(false) { source = locator.findPage(VIEW) } + + then: + source instanceof GroovyPageCompiledScriptSource + } + + void 'a compiled page is used in an ahead-of-time image even though it looks like development'() { + when: 'the executable is run from the directory it was built in' + def source = null + withAot(true) { source = developmentLocator().findPage(VIEW) } + + then: 'reading the sources would render nothing, because no class can be defined' + source instanceof GroovyPageCompiledScriptSource + } + + void 'development still prefers the sources so that an edit takes effect'() { + when: + def source = null + withAot(false) { source = developmentLocator().findPage(VIEW) } + + then: 'no source file exists here, so nothing is found rather than the compiled page' + !(source instanceof GroovyPageCompiledScriptSource) + } + + /** + * Stands in for a page the build compiled. The constants are the ones the compiler emits and the + * page's metadata is read from, so the locator can treat this like any other compiled page. + */ + static class CompiledPage extends org.grails.gsp.GroovyPage { + + public static final String CONTENT_TYPE = 'text/html;charset=UTF-8' + + public static final Map JSP_TAGS = [:] + + public static final Long LAST_MODIFIED = 0L + + public static final String EXPRESSION_CODEC = 'html' + + public static final String STATIC_CODEC = 'none' + + public static final String OUT_CODEC = 'none' + + public static final String TAGLIB_CODEC = 'none' + + @Override + String getGroovyPageFileName() { + 'index.gsp' + } + + @Override + Object run() { + null + } + } +} diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java index a537f3df971..7b6b38d50c2 100644 --- a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java +++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java @@ -37,7 +37,14 @@ public class GrailsSiteMeshViewResolver extends SiteMeshViewResolver { private final ContentProcessor contentProcessor; private final DecoratorSelector decoratorSelector; - private final ServletContext servletContext; + + public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver, + ContentProcessor contentProcessor, + DecoratorSelector decoratorSelector) { + super(innerViewResolver, contentProcessor, decoratorSelector); + this.contentProcessor = contentProcessor; + this.decoratorSelector = decoratorSelector; + } public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver, ContentProcessor contentProcessor, @@ -46,7 +53,6 @@ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver, super(innerViewResolver, contentProcessor, decoratorSelector, servletContext); this.contentProcessor = contentProcessor; this.decoratorSelector = decoratorSelector; - this.servletContext = servletContext; } @Override @@ -54,7 +60,7 @@ protected SiteMeshView createSiteMeshView(View innerView) { // Forward-based JSP inner views are switched to include dispatch by // SiteMeshViewResolver.prepareForBufferedRender (keyed on // DispatchMode) before this hook runs. - return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, servletContext, + return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, getServletContext(), getInnerViewResolver()); } } diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy new file mode 100644 index 00000000000..3cc5b936503 --- /dev/null +++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.sitemesh3 + +import jakarta.servlet.ServletContext + +import org.sitemesh.DecoratorSelector +import org.sitemesh.SiteMeshContext +import org.sitemesh.content.ContentProcessor +import org.sitemesh.content.tagrules.TagBasedContentProcessor +import org.sitemesh.content.tagrules.html.CoreHtmlTagRuleBundle +import org.springframework.beans.factory.support.GenericBeanDefinition +import org.springframework.mock.web.MockServletContext +import org.springframework.web.context.support.GenericWebApplicationContext +import org.springframework.web.servlet.View +import org.springframework.web.servlet.view.InternalResourceViewResolver +import spock.lang.Specification + +/** + * Covers the resolver being built without a servlet context argument and receiving one from the + * container instead. A plain bean factory applies no {@code ServletContextAware} callback, so the + * path only exists in a real web application context. + */ +class GrailsSiteMeshViewResolverServletContextSpec extends Specification { + + MockServletContext servletContext = new MockServletContext() + GenericWebApplicationContext context = new GenericWebApplicationContext(servletContext) + + void setup() { + def target = new GenericBeanDefinition() + target.beanClass = InternalResourceViewResolver + context.registerBeanDefinition('jspViewResolver', target) + + // definitions rather than singletons: the post-processor stands down unless the SiteMesh + // collaborators are present as bean definitions + context.registerBeanDefinition('contentProcessor', + new GenericBeanDefinition(beanClass: CaptureAwareContentProcessor)) + def selector = new GenericBeanDefinition(beanClass: Sitemesh3LayoutFinder, lazyInit: true) + selector.constructorArgumentValues.addIndexedArgumentValue(0, null) + context.registerBeanDefinition('decoratorSelector', selector) + } + + void cleanup() { + context.close() + } + + void 'the rewritten resolver takes its servlet context from the container'() { + given: + new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context) + + when: + context.refresh() + + then: 'nothing declares a servletContext bean definition for it to reference' + !context.containsBeanDefinition('servletContext') + + and: + def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver) + resolver.servletContext.is(servletContext) + } + + void 'the injected servlet context reaches the view the resolver produces'() { + given: + new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context) + context.refresh() + + when: 'a view is resolved, which is where the servlet context is read' + def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver) + View view = resolver.resolveViewName('someView', Locale.ENGLISH) + + then: + view instanceof GrailsSiteMeshView + ((GrailsSiteMeshView) view).servletContext.is(servletContext) + } + + void 'a resolver built with an explicit servlet context still carries it'() { + given: 'the constructor callers outside the container callback use' + ContentProcessor processor = new TagBasedContentProcessor(new CoreHtmlTagRuleBundle()) + DecoratorSelector selector = { content, ctx -> new String[0] } + ServletContext explicit = new MockServletContext() + + when: + def resolver = new GrailsSiteMeshViewResolver( + new InternalResourceViewResolver(), processor, selector, explicit) + + then: + resolver.servletContext.is(explicit) + } +} diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy index 3d1b0ae61b6..ae982ed436c 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy @@ -21,9 +21,12 @@ package org.grails.plugins.web import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import org.springframework.beans.factory.BeanRegistry +import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.config.PropertiesFactoryBean import org.springframework.boot.web.servlet.ServletRegistrationBean -import org.springframework.core.io.Resource +import org.springframework.context.aot.AbstractAotProcessor +import org.springframework.core.SpringProperties import org.springframework.util.ClassUtils import org.springframework.web.servlet.view.InternalResourceViewResolver @@ -103,6 +106,24 @@ class GroovyPagesGrailsPlugin extends Plugin { applicationContext.getBean('groovyPagesTemplateEngine', GroovyPagesTemplateEngine).clearPageCache() } + /** + * Tag library beans autowire by name and are read from the artefacts the application knows + * about, which the {@link org.springframework.beans.factory.BeanRegistry} API cannot express, so + * their definitions are contributed by a dedicated post-processor. Contributing them here rather + * than from {@code doWithSpring()} leaves the definitions an ahead-of-time image generated, and + * the injection generated with them, in place. + */ + @Override + BeanRegistrar beanRegistrar() { + { BeanRegistry registry, org.springframework.core.env.Environment environment -> + registry.registerBean('tagLibBeanDefinitionsPostProcessor', TagLibBeanDefinitionsPostProcessor) { + it.infrastructure().supplier { + new TagLibBeanDefinitionsPostProcessor(grailsApplication) + } + } + } as BeanRegistrar + } + /** * Configures the various Spring beans required by GSP */ @@ -166,27 +187,22 @@ class GroovyPagesGrailsPlugin extends Plugin { } } - def deployed = !Metadata.getCurrent().isDevelopmentEnvironmentAvailable() groovyPageLocator(CachingGrailsConventionGroovyPageLocator) { bean -> bean.lazyInit = true if (customResourceLoader) { resourceLoader = groovyPageResourceLoader } - if (deployed) { - Resource defaultViews = applicationContext?.getResource('gsp/views.properties') - - if (defaultViews != null) { - if (!defaultViews.exists()) { - defaultViews = applicationContext?.getResource('classpath:gsp/views.properties') - } - } - - if (defaultViews?.exists()) { - precompiledGspMap = { PropertiesFactoryBean pfb -> - ignoreResourceNotFound = true - locations = [defaultViews] as Resource[] - } - } + // Where the pages compiled at build time are listed. Attached whatever the + // surroundings, because whether to read from it is decided where a page is looked + // up, at run time, and only there is the answer knowable: deciding it here settles + // it while the definition is being generated, in the directory the application was + // built in, where a development environment is available -- so an image would be + // built believing it has to compile its pages, which is the one thing it cannot do. + // Named rather than resolved, so that what is written down is a location to look in + // and not a path on the machine that did the building. + precompiledGspMap = { PropertiesFactoryBean pfb -> + ignoreResourceNotFound = true + locations = ['gsp/views.properties', 'classpath:gsp/views.properties'] } if (enableReload) { cacheTimeout = gspCacheTimeout @@ -250,24 +266,10 @@ class GroovyPagesGrailsPlugin extends Plugin { // Configure a Spring MVC view resolver if none is defined groovyPagesPostProcessor(GroovyPagesPostProcessor) - // Now go through tag libraries and configure them in Spring too. With AOP proxies and so on - for (taglib in application.tagLibClasses) { - - final tagLibClass = taglib.clazz - - "${taglib.fullName}"(tagLibClass) { bean -> - bean.autowire = true - bean.lazyInit = true - - // Taglib scoping support could be easily added here. Scope could be based on a static field in the taglib class. - //bean.scope = 'request' - } - } - errorsViewStackTracePrinter(ErrorsViewStackTracePrinter, ref('grailsResourceLocator')) - filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, application) + filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, ref('grailsApplication')) - groovyPagesServlet(ServletRegistrationBean, new GroovyPagesServlet(), '*.gsp') { + groovyPagesServlet(ServletRegistrationBean, bean(GroovyPagesServlet), '*.gsp') { if (Environment.isDevelopmentMode()) { initParameters = [showSource: '1'] } @@ -277,7 +279,22 @@ class GroovyPagesGrailsPlugin extends Plugin { } } + /** + * Whether the application is being developed, which decides where its pages are read from and + * whether they are watched for change. + * + *

    While code is being generated the answer is no, whatever the machine doing the generating + * looks like. Generation runs in the project directory, so a development environment is + * available there and the question would otherwise be answered for the machine that built the + * application rather than the one that runs it: the pages would be read from a directory that + * exists only on the build machine, whose path would be written into the artifact, and an image + * cannot compile a page it finds there in any case.

    + */ + @CompileStatic protected boolean isDevelopmentMode() { + if (SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING)) { + return false + } Metadata.getCurrent().isDevelopmentEnvironmentAvailable() } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy new file mode 100644 index 00000000000..111f436141e --- /dev/null +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.beans.factory.support.AbstractBeanDefinition +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor +import org.springframework.beans.factory.support.GenericBeanDefinition +import org.springframework.core.PriorityOrdered + +import grails.core.GrailsApplication +import grails.core.gsp.GrailsTagLibClass +import org.grails.core.artefact.gsp.TagLibArtefactHandler + +/** + * Registers a bean definition for every tag library artefact, replacing the registration the GSP + * plugin previously performed through the {@code doWithSpring()} bean DSL. + * + *

    Registering them there meant registering them again on every start, over whatever was already + * there. That is harmless on a running JVM, but an ahead-of-time image has already generated a + * definition for each tag library, carrying the field and method injection the generator worked out + * at build time. Replacing it discarded that injection, leaving a tag library holding null where it + * expected a collaborator -- and by-name autowiring did not stand in for it, because the + * collaborators are fields rather than properties. Contributing the definitions from here leaves the + * generated ones alone.

    + * + *

    The artefacts are read from the application rather than named individually, so a tag library + * belongs to whoever declared it: the application, another plugin, or one supplied through + * {@code providedArtefacts}. An existing definition for the same name wins untouched, which + * preserves the ability to override a tag library -- including one deliberately declared without + * by-name autowiring, which cannot be told apart from a generated one. What a generated definition + * needs is carried into it while it is generated, by the processor that reads the autowire mode.

    + * + * @since 8.0 + */ +@Slf4j +@CompileStatic +class TagLibBeanDefinitionsPostProcessor implements BeanDefinitionRegistryPostProcessor, PriorityOrdered { + + private final GrailsApplication grailsApplication + + TagLibBeanDefinitionsPostProcessor(GrailsApplication grailsApplication) { + this.grailsApplication = grailsApplication + } + + @Override + void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { + for (Object each : grailsApplication.getArtefacts(TagLibArtefactHandler.TYPE)) { + GrailsTagLibClass artefact = (GrailsTagLibClass) each + String beanName = artefact.fullName + if (!artefact.available) { + continue + } + if (registry.containsBeanDefinition(beanName)) { + continue + } + GenericBeanDefinition definition = new GenericBeanDefinition( + beanClass: artefact.clazz, + lazyInit: true, + autowireMode: AbstractBeanDefinition.AUTOWIRE_BY_NAME + ) + registry.registerBeanDefinition(beanName, definition) + log.debug('Registered tag library {}', beanName) + } + } + + @Override + void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { + } + + @Override + int getOrder() { + HIGHEST_PRECEDENCE + } +} diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java new file mode 100644 index 00000000000..1429d9a1087 --- /dev/null +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHints.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web.taglib.aot; + +import java.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.util.ClassUtils; + +import org.grails.aot.RegistrableTypes; + +/** + * Registers the tag libraries and page runtime a rendered page dispatches through. + * + *

    A tag is resolved by name and invoked reflectively, so an image that keeps only the members + * something asked for renders a page until it reaches a tag it stripped. Which tags those are + * depends on what the page does: a field is only rendered by a form, and a flash message only + * exists after a redirect that set one, so a walk of an application's pages exercises neither and + * they fail for the first person who edits something.

    + * + *

    Tag libraries are found rather than named. A plugin declares its own in whatever package it + * chooses, and its tags are as reachable from a page as the framework's own.

    + * + * @since 8.0 + */ +public class TagLibRuntimeHints implements RuntimeHintsRegistrar { + + private static final Log logger = LogFactory.getLog(TagLibRuntimeHints.class); + + /** + * A tag library is identified by its name, wherever it is declared. The trailing wildcard also + * takes the classes it declares inside itself: the fields plugin keeps the stack a nested tag + * reads its bean from in one, and a tag that never nests does not reach it. + */ + private static final String TAGLIB_PATTERN = + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "**/*TagLib*.class"; + + /** + * The page rendering runtime. Registered by package rather than by name: a compiled page reaches + * all of it through Groovy, down to writing its output with an operator, and naming the types + * one at a time only ever describes the pages that have been rendered so far. + */ + private static final String[] RUNTIME_PATTERNS = { + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/gsp/**/*.class", + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/taglib/**/*.class", + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/buffer/**/*.class" + }; + + private static String[] patterns() { + String[] all = new String[RUNTIME_PATTERNS.length + 1]; + System.arraycopy(RUNTIME_PATTERNS, 0, all, 0, RUNTIME_PATTERNS.length); + all[RUNTIME_PATTERNS.length] = TAGLIB_PATTERN; + return all; + } + + /** + * Types the plugin descriptors call. A descriptor is Groovy, so even a static call on a utility + * class is dispatched dynamically and needs the class to survive. + */ + private static final String[] CALLED_FROM_DESCRIPTORS = { + "org.springframework.aot.AotDetector" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader(); + for (String type : CALLED_FROM_DESCRIPTORS) { + hints.reflection().registerTypeIfPresent(loader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS); + } + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader); + MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver); + int registered = 0; + for (String pattern : patterns()) { + Resource[] resources; + try { + resources = resolver.getResources(pattern); + } + catch (IOException ex) { + logger.warn("Unable to scan for " + pattern, ex); + continue; + } + for (Resource resource : resources) { + String className; + try { + className = metadataReaderFactory.getMetadataReader(resource) + .getClassMetadata().getClassName(); + } + catch (IOException | RuntimeException ex) { + continue; + } + if (!RegistrableTypes.loads(className, loader)) { + continue; + } + // declared rather than public throughout: a tag's body may call a private helper on + // its own library, and a page reads the shared empty body as a field + hints.reflection().registerTypeIfPresent(loader, className, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.ACCESS_DECLARED_FIELDS, + MemberCategory.ACCESS_PUBLIC_FIELDS); + registered++; + } + } + logger.debug("Registered " + registered + " tag library and page runtime types"); + } +} diff --git a/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories b/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..e56c5b482c9 --- /dev/null +++ b/grails-gsp/plugin/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.plugins.web.taglib.aot.TagLibRuntimeHints diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy new file mode 100644 index 00000000000..591e36bc703 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web + +import grails.core.DefaultGrailsApplication +import grails.core.GrailsApplication + +import org.springframework.beans.factory.support.AbstractBeanDefinition +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import spock.lang.Specification + +/** + * Covers tag library definitions being contributed without replacing what is already registered. + * + *

    The GSP plugin used to register them from {@code doWithSpring()}, over whatever was there. An + * ahead-of-time image has already generated a definition for each one, carrying the injection the + * generator worked out, and replacing it discarded that.

    + */ +class TagLibBeanDefinitionsPostProcessorSpec extends Specification { + + BeanDefinitionRegistry registry = new DefaultListableBeanFactory() + + GrailsApplication grailsApplication + + void setup() { + grailsApplication = new DefaultGrailsApplication(DemoTagLib) + grailsApplication.initialise() + } + + private void process() { + new TagLibBeanDefinitionsPostProcessor(grailsApplication).postProcessBeanDefinitionRegistry(registry) + } + + void 'a tag library the application knows about is registered'() { + when: + process() + + then: + registry.containsBeanDefinition(DemoTagLib.name) + } + + void 'the definition is lazy and autowired by name'() { + when: + process() + AbstractBeanDefinition definition = + (AbstractBeanDefinition) registry.getBeanDefinition(DemoTagLib.name) + + then: 'a tag library takes some collaborators by name rather than by annotation' + definition.lazyInit + definition.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_NAME + } + + void 'a definition that is already registered is kept'() { + given: 'the definition an ahead-of-time image generated, carrying its own injection' + RootBeanDefinition generated = new RootBeanDefinition(DemoTagLib) + registry.registerBeanDefinition(DemoTagLib.name, generated) + + when: + process() + + then: + registry.getBeanDefinition(DemoTagLib.name).is(generated) + } + + void 'a definition that declares no autowiring is left declaring none'() { + given: 'an application that deliberately took by-name autowiring off its own tag library' + RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib) + declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_NO + registry.registerBeanDefinition(DemoTagLib.name, declared) + + when: + process() + + then: 'which cannot be told apart from a generated one, so neither is touched -- what a ' + + 'generated definition needs is carried into it while it is generated' + declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_NO + } + + void 'a definition asking for something else keeps it'() { + given: + RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib) + declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_BY_TYPE + registry.registerBeanDefinition(DemoTagLib.name, declared) + + when: + process() + + then: + declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_TYPE + } + + /** Recognised as a tag library by its name, which is what the artefact handler reads. */ + static class DemoTagLib { + + static namespace = 'demo' + + Closure hello = { attrs -> } + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy new file mode 100644 index 00000000000..2b63d2408a4 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/taglib/aot/TagLibRuntimeHintsSpec.groovy @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.plugins.web.taglib.aot + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.TypeReference +import spock.lang.Specification + +/** + * Covers the tag libraries and page runtime surviving into an ahead-of-time image. What a page + * reaches depends on what it renders, so a missing registration shows up on the page that uses that + * tag rather than at start-up. + */ +class TagLibRuntimeHintsSpec extends Specification { + + RuntimeHints hints = new RuntimeHints() + + void setup() { + new TagLibRuntimeHints().registerHints(hints, getClass().classLoader) + } + + private Set registeredTypes() { + hints.reflection().typeHints().collect { it.type.name } as Set + } + + private boolean hasCategory(String type, MemberCategory category) { + def hint = hints.reflection().getTypeHint(TypeReference.of(type)) + hint != null && hint.memberCategories.contains(category) + } + + void 'the tag libraries on the classpath are registered'() { + expect: + registeredTypes().any { it.endsWith('TagLib') } + } + + void 'the classes a tag library declares inside itself are registered too'() { + expect: 'the fields plugin keeps the bean stack a nested tag reads in one of these, so a ' + + 'pattern matching only the library itself renders a page until a tag nests' + registeredTypes().any { it.contains('TagLib$') } + } + + void 'the page runtime a compiled page writes through is registered'() { + expect: + registeredTypes().any { it.startsWith('org.grails.gsp.') } + registeredTypes().any { it.startsWith('org.grails.buffer.') } + } + + void 'fields are registered, not only methods'() { + given: 'a page reads the shared empty body from the tag output rather than calling for it' + String type = registeredTypes().find { it.startsWith('org.grails.taglib.') } + + expect: + hasCategory(type, MemberCategory.ACCESS_DECLARED_FIELDS) + hasCategory(type, MemberCategory.INVOKE_DECLARED_METHODS) + } + + void 'a class loader that resolves nothing yields no hints rather than failing'() { + given: + RuntimeHints empty = new RuntimeHints() + + when: + new TagLibRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null)) + + then: + noExceptionThrown() + } +} diff --git a/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java new file mode 100644 index 00000000000..80b40ef22c2 --- /dev/null +++ b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.web.mime.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the content negotiation API. + * + *

    Negotiation runs only for a request that states what it accepts. A browser always does; + * a bare command-line request does not, which is why the absence of these hints can pass an + * automated check and still fail for every real visitor.

    + * + * @since 8.0 + */ +public class MimeTypeRuntimeHints implements RuntimeHintsRegistrar { + + /** + * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays + * correct for an application that does not use every plugin. + */ + private static final String[] DISPATCHED_TYPES = { + "grails.web.mime.MimeType", + "org.grails.web.mime.DefaultAcceptHeaderParser", + "org.grails.web.mime.DefaultMimeUtility" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : DISPATCHED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } +} diff --git a/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..e6d118d9714 --- /dev/null +++ b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.web.mime.aot.MimeTypeRuntimeHints diff --git a/grails-profiles/web/skeleton/grails-app/views/index.gsp b/grails-profiles/web/skeleton/grails-app/views/index.gsp index 97362a9246a..add9c633cc9 100644 --- a/grails-profiles/web/skeleton/grails-app/views/index.gsp +++ b/grails-profiles/web/skeleton/grails-app/views/index.gsp @@ -2,6 +2,7 @@ <%@ page import="org.springframework.boot.SpringBootVersion"%> <%@ page import="org.springframework.core.SpringVersion"%> <%@ page import="org.springframework.util.ClassUtils"%> +<%@ page import="org.springframework.util.ReflectionUtils"%> ${SpringVersion.getVersion()}
  • - <%-- Spring Security: only when the dependency is present --%> + <%-- Spring Security: only when the dependency is present. The call goes + through ReflectionUtils because invoking Method.invoke from Groovy + resolves to a caller-sensitive overload that a native image rejects. --%> + + value="${springSecurityCoreVersionClass ? ReflectionUtils.invokeMethod(ReflectionUtils.findMethod(springSecurityCoreVersionClass, 'getVersion'), null) : null}"/>
  • diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java new file mode 100644 index 00000000000..a8b018804cb --- /dev/null +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/aot/ScaffoldingRuntimeHints.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 grails.plugin.scaffolding.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the controller a scaffolded resource is served by. + * + *

    Its actions are reached through Groovy, including the protected ones it defines for the write + * operations, so an image that keeps only the members something asked for serves the pages and then + * fails on the request that saves or removes a record.

    + * + * @since 8.0 + */ +public class ScaffoldingRuntimeHints implements RuntimeHintsRegistrar { + + private static final String[] DISPATCHED_TYPES = { + "grails.plugin.scaffolding.RestfulServiceController", + "grails.plugin.scaffolding.ScaffoldingViewResolver", + "grails.plugin.scaffolding.annotation.Scaffold" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : DISPATCHED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.ACCESS_DECLARED_FIELDS); + } + } +} diff --git a/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories b/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..85ba8176d40 --- /dev/null +++ b/grails-scaffolding/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +grails.plugin.scaffolding.aot.ScaffoldingRuntimeHints diff --git a/grails-spring/build.gradle b/grails-spring/build.gradle index e0aaa04a5bc..0ee57f7d690 100644 --- a/grails-spring/build.gradle +++ b/grails-spring/build.gradle @@ -62,4 +62,5 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') } \ No newline at end of file diff --git a/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java b/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java index fa43f506078..0a97b8e452d 100644 --- a/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java +++ b/grails-spring/src/main/groovy/org/grails/spring/DefaultRuntimeSpringConfiguration.java @@ -33,6 +33,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.aot.AotDetector; import org.springframework.beans.PropertyValue; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.config.BeanDefinition; @@ -265,11 +266,36 @@ public void registerBeansWithRegistry(BeanDefinitionRegistry registry) { private void registerUnrefreshedBeansWithRegistry(BeanDefinitionRegistry registry) { if (context != null) { for (String beanName : context.getBeanDefinitionNames()) { - registry.registerBeanDefinition(beanName, context.getBeanDefinition(beanName)); + register(registry, beanName, context.getBeanDefinition(beanName)); } } } + /** + * Registers a definition, leaving in place one that was generated ahead of time. + * + *

    Running on generated artifacts, the plugins that produced these definitions already ran: + * they ran while the artifacts were being generated, and what they registered was written out as + * code and registered again here, ahead of this phase. Registering over that discards the + * instance supplier the generator wrote -- which resolves the constructor, the fields and the + * injection methods up front -- and replaces it with a definition that has to find all of that + * by reflection, which is what a generated image does not carry. So the generated one stands and + * this one is dropped.

    + * + *

    Anything the generator did not produce a definition for is registered as usual, so a bean a + * plugin contributes conditionally is unaffected. On a normal start nothing is skipped, and a + * plugin overrides what came before it exactly as it did.

    + */ + private void register(BeanDefinitionRegistry registry, String beanName, BeanDefinition definition) { + if (AotDetector.useGeneratedArtifacts() && registry.containsBeanDefinition(beanName)) { + if (LOG.isDebugEnabled()) { + LOG.debug("[RuntimeConfiguration] Keeping the generated definition of bean [" + beanName + "]"); + } + return; + } + registry.registerBeanDefinition(beanName, definition); + } + private void registerBeanConfigsWithRegistry(BeanDefinitionRegistry registry) { for (BeanConfiguration bc : beanConfigs.values()) { String beanName = bc.getName(); @@ -285,7 +311,7 @@ private void registerBeanConfigsWithRegistry(BeanDefinitionRegistry registry) { } } - registry.registerBeanDefinition(beanName, bc.getBeanDefinition()); + register(registry, beanName, bc.getBeanDefinition()); } } @@ -302,7 +328,7 @@ private void registerBeanDefinitionsWithRegistry(BeanDefinitionRegistry registry } } final String beanName = key.toString(); - registry.registerBeanDefinition(beanName, bd); + register(registry, beanName, bd); } } diff --git a/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy b/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy new file mode 100644 index 00000000000..827388e09eb --- /dev/null +++ b/grails-spring/src/test/groovy/org/grails/spring/DefaultRuntimeSpringConfigurationAotSpec.groovy @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.spring + +import org.springframework.aot.AotDetector +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import org.springframework.core.SpringProperties +import spock.lang.Specification + +/** + * Covers plugin bean definitions being registered without replacing ones generated ahead of time. + * + *

    Running on generated artifacts, the plugins that produced these definitions already ran: they + * ran while the artifacts were being generated, and what they registered was written out as code and + * registered again ahead of this phase. Registering over that discards the instance supplier the + * generator wrote and replaces it with a definition that finds everything by reflection.

    + */ +class DefaultRuntimeSpringConfigurationAotSpec extends Specification { + + DefaultListableBeanFactory registry = new DefaultListableBeanFactory() + + DefaultRuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration() + + void cleanup() { + SpringProperties.setProperty(AotDetector.AOT_ENABLED, null) + } + + private void runningOnGeneratedArtifacts(boolean enabled) { + SpringProperties.setProperty(AotDetector.AOT_ENABLED, String.valueOf(enabled)) + } + + /** A definition contributed the way a plugin's {@code doWithSpring()} contributes one. */ + private void contribute(String beanName, Class beanClass) { + springConfig.addSingletonBean(beanName, beanClass) + } + + void 'a generated definition is kept'() { + given: + runningOnGeneratedArtifacts(true) + RootBeanDefinition generated = new RootBeanDefinition(Collaborator) + registry.registerBeanDefinition('subject', generated) + contribute('subject', Collaborator) + + when: + springConfig.registerBeansWithRegistry(registry) + + then: + registry.getBeanDefinition('subject').is(generated) + } + + void 'a definition the generator did not produce is still registered'() { + given: + runningOnGeneratedArtifacts(true) + contribute('conditional', Collaborator) + + when: + springConfig.registerBeansWithRegistry(registry) + + then: 'a bean a plugin contributes conditionally has nothing generated for it' + registry.containsBeanDefinition('conditional') + } + + void 'a plugin overrides what came before it on a normal start'() { + given: + runningOnGeneratedArtifacts(false) + RootBeanDefinition earlier = new RootBeanDefinition(Collaborator) + registry.registerBeanDefinition('subject', earlier) + contribute('subject', Collaborator) + + when: + springConfig.registerBeansWithRegistry(registry) + + then: 'the ordering plugins rely on to replace one another is unchanged' + !registry.getBeanDefinition('subject').is(earlier) + } + + static class Collaborator { + } +} diff --git a/grails-test-examples/aot/build.gradle b/grails-test-examples/aot/build.gradle new file mode 100644 index 00000000000..18fee968659 --- /dev/null +++ b/grails-test-examples/aot/build.gradle @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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. + */ + +plugins { + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.vulnerability-scan' +} + +version = '0.1' +group = 'functionaltests' + +apply plugin: 'org.apache.grails.gradle.grails-web' +apply plugin: 'org.apache.grails.gradle.grails-gsp' +apply plugin: 'org.springframework.boot.aot' + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') +} + +dependencies { + implementation platform(project(':grails-bom')) + + implementation 'org.apache.grails:grails-dependencies-starter-web' + implementation 'org.apache.grails:grails-sitemesh3' + + testImplementation 'org.apache.grails:grails-testing-support-web' +} + +// Generation reflects the environment the packaged application runs in. Under development, +// reloading is enabled and beans such as the url mappings holder take a proxied shape that has +// no meaning in a packaged artifact and cannot be generated. +tasks.named('processAot') { + systemProperty 'grails.env', 'production' +} + +// The application must also start from what was generated, which is the half that catches a +// runtime-only regression - a context that generates cleanly can still fail to boot. +tasks.register('aotStartupCheck', JavaExec) { + dependsOn tasks.named('bootJar') + group = 'verification' + description = 'Starts the packaged application with AOT enabled and asserts the context comes up' + classpath = files(tasks.named('bootJar').flatMap { it.archiveFile }) + mainClass = 'org.springframework.boot.loader.launch.JarLauncher' + systemProperties([ + 'spring.aot.enabled': 'true', + 'grails.env' : 'production', + ]) + args = ['--aot-startup-check', '--server.port=0', '--spring.main.banner-mode=off'] +} + +tasks.named('check') { + dependsOn tasks.named('aotStartupCheck') +} diff --git a/grails-test-examples/aot/grails-app/conf/application.yml b/grails-test-examples/aot/grails-app/conf/application.yml new file mode 100644 index 00000000000..4314f81391d --- /dev/null +++ b/grails-test-examples/aot/grails-app/conf/application.yml @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# 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. + +grails: + profile: web +info: + app: + name: aot diff --git a/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy b/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy new file mode 100644 index 00000000000..5125b0cca5a --- /dev/null +++ b/grails-test-examples/aot/grails-app/controllers/aot/GreetingController.groovy @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 aot + +/** + * Serves the one page this application has, so that the request path is exercised rather than only + * the start-up: a context that comes up cleanly can still fail on the first thing anyone asks it for. + */ +class GreetingController { + + def index() { + [name: 'ahead of time'] + } +} diff --git a/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy b/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy new file mode 100644 index 00000000000..c9c95d37329 --- /dev/null +++ b/grails-test-examples/aot/grails-app/controllers/aot/UrlMappings.groovy @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 aot + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?" { } + "/"(controller: 'greeting', action: 'index') + // no 500 or 404 view is mapped: this application exists to fail loudly, and a mapping to a + // page it does not have would answer a real failure with a second one about the error page + } +} diff --git a/grails-test-examples/aot/grails-app/init/aot/Application.groovy b/grails-test-examples/aot/grails-app/init/aot/Application.groovy new file mode 100644 index 00000000000..21a35a5c958 --- /dev/null +++ b/grails-test-examples/aot/grails-app/init/aot/Application.groovy @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 aot + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +import org.springframework.context.ConfigurableApplicationContext + +class Application extends GrailsAutoConfiguration { + + /** + * With {@code --aot-startup-check} the application starts, asks itself for the one page it has, + * and exits. The build runs it that way against the packaged jar with + * {@code spring.aot.enabled=true}. + * + *

    The page is the point. Almost everything an application generated ahead of time gets wrong + * is something that starts perfectly well: a tag library holding null where a collaborator wired + * by name should be, a link generator whose own collaborator was injected into a field of an + * implementation the container only knows as an interface, a page looked up in a manifest an + * image did not carry. None of it is visible until something renders, so a check that only reads + * the bean names would pass while every one of those was broken.

    + */ + static void main(String[] args) { + if (!args.contains('--aot-startup-check')) { + GrailsApp.run(Application, args) + return + } + + ConfigurableApplicationContext context = (ConfigurableApplicationContext) GrailsApp.run(Application, args) + try { + assertBeanPresent(context, 'grailsApplication') + assertBeanPresent(context, 'filteringCodecsByContentTypeSettings') + assertBeanPresent(context, 'groovyPagesServlet') + assertBeanPresent(context, 'jspViewResolver') + assertBeanPresent(context, 'grailsUrlMappingsHolder') + // contributed by a plugin's @Configuration class, which is parsed by the processor the + // core plugin stands down in favour of once the definitions are generated + assertBeanPresent(context, 'propertySourcesPlaceholderConfigurer') + assertPageRenders(context) + } + finally { + context.close() + } + System.exit(0) + } + + private static void assertBeanPresent(ConfigurableApplicationContext context, String name) { + if (!context.containsBean(name)) { + throw new IllegalStateException("AOT-processed context is missing the '${name}' bean") + } + } + + /** Asks the running application for its page and checks what came back rendered. */ + private static void assertPageRenders(ConfigurableApplicationContext context) { + Integer port = context.environment.getProperty('local.server.port', Integer) + if (port == null) { + throw new IllegalStateException('The application reported no port, so it is not serving') + } + String page = get(port, '/greeting/index') + assertRendered(page, 'hello from a service', + 'the tag library rendered without the collaborator it takes by name') + assertRendered(page, 'ahead of time', 'the action did not reach the page it returned') + + // Followed rather than matched against a path written here. What the generator produces is + // whatever the mappings reverse to -- this application maps the action to the root -- so the + // question worth asking is whether the link it built leads back to the page it was built on. + String link = between(page, '') + if (!link) { + throw new IllegalStateException("AOT-processed application: the link generator built no " + + "link. Page was:\n${page}") + } + assertRendered(get(port, link), 'hello from a service', + "the link the generator built (${link}) does not lead back to the page") + } + + private static String get(int port, String path) { + new URI("http://localhost:${port}${path}").toURL().getText('UTF-8') + } + + private static String between(String page, String start, String end) { + int from = page.indexOf(start) + if (from < 0) { + return null + } + int to = page.indexOf(end, from + start.length()) + to < 0 ? null : page.substring(from + start.length(), to).trim() + } + + private static void assertRendered(String page, String expected, String whatItMeans) { + if (!page.contains(expected)) { + throw new IllegalStateException( + "AOT-processed application: ${whatItMeans}. Expected '${expected}' in:\n${page}") + } + } +} diff --git a/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy b/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy new file mode 100644 index 00000000000..1c7a516e1bb --- /dev/null +++ b/grails-test-examples/aot/grails-app/services/aot/GreetingService.groovy @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 aot + +/** + * A collaborator the tag library takes by name, which is how Grails wires most of what it + * contributes: no annotation says to inject this, only that the bean and the property share a name. + */ +class GreetingService { + + String greeting() { + 'hello from a service' + } +} diff --git a/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy b/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy new file mode 100644 index 00000000000..994fe11723d --- /dev/null +++ b/grails-test-examples/aot/grails-app/taglib/aot/GreetingTagLib.groovy @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 aot + +/** + * Renders what the service says. + * + *

    Its collaborator arrives by name rather than by annotation, which is the arrangement the + * generator writes out nothing for -- so a page reaching this tag is what tells the two apart: a + * tag library rebuilt from generated code without its autowire mode holds null here, and says so + * only when something renders it.

    + */ +class GreetingTagLib { + + static namespace = 'aot' + + def greetingService + + def greeting = { attrs -> + out << greetingService.greeting() + } +} diff --git a/grails-test-examples/aot/grails-app/views/greeting/index.gsp b/grails-test-examples/aot/grails-app/views/greeting/index.gsp new file mode 100644 index 00000000000..23d3b75444d --- /dev/null +++ b/grails-test-examples/aot/grails-app/views/greeting/index.gsp @@ -0,0 +1,35 @@ +<%-- + ~ Licensed to the Apache Software Foundation (ASF) under one + ~ or more contributor license agreements. See the NOTICE file + ~ distributed with this work for additional information + ~ regarding copyright ownership. The ASF licenses this file + ~ to you under the Apache License, Version 2.0 (the + ~ "License"); you may not use this file except in compliance + ~ with the License. You may obtain a copy of the License at + ~ + ~ 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. + --%> +<%-- + Every line here is one of the things that only fails once a page is rendered. + + The page itself is found through the manifest of pages compiled at build time, which an image + reads because it cannot compile one. The tag reaches a collaborator wired by name. The link is + built by the link generator, whose own collaborator is injected into a field of an implementation + the container only knows as an interface. The model value proves the action ran. +--%> + + +ahead of time + +

    +

    ${name}

    + + + diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy index 1824e739b36..24b7680c775 100644 --- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy +++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy @@ -30,6 +30,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.ApplicationContext +import org.springframework.context.aot.AbstractAotProcessor +import org.springframework.core.SpringProperties import org.springframework.core.env.Environment import org.springframework.web.filter.CorsFilter @@ -117,8 +119,7 @@ class UrlMappingsGrailsPlugin extends Plugin { grailsApplication.addArtefact(UrlMappingsArtefactHandler.TYPE, DefaultUrlMappings) } - boolean reloadEnabled = GrailsEnvironment.developmentMode || - GrailsEnvironment.current.reloadEnabled + boolean reloadEnabled = isReloadEnabled() boolean corsFilterEnabled = environment.getProperty(Settings.SETTING_CORS_FILTER, Boolean, true) // The url-mapping holder is a ProxyFactoryBean (reload mode) whose produced UrlMappings @@ -155,6 +156,25 @@ class UrlMappingsGrailsPlugin extends Plugin { } } + /** + * Whether the mappings are to be reloadable, which decides how the holder is defined. + * + *

    Not while the code is being generated, whatever the machine generating it looks like. + * Reloading swaps the mappings behind a proxy, and the proxy produces its {@code UrlMappings} + * through a target source rather than declaring the type -- so Spring can only learn what it + * produces by building it, which is exactly what reading a generated definition avoids. + * Generated that way nothing could be autowired by that type, and the application did not + * start. An image cannot reload anything in any case.

    + */ + protected boolean isReloadEnabled() { + !SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING) && environmentReloadable + } + + /** Whether the surroundings are ones that reload, which only a run can answer. */ + protected boolean isEnvironmentReloadable() { + GrailsEnvironment.developmentMode || GrailsEnvironment.current.reloadEnabled + } + @CompileStatic private static UrlMappingsHolder createUrlMappingsHolder(ApplicationContext applicationContext) { def factory = new UrlMappingsHolderFactoryBean(applicationContext: applicationContext) diff --git a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy index 289100a3f0b..f4a97541762 100644 --- a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy +++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy @@ -23,6 +23,7 @@ import org.springframework.beans.factory.config.RuntimeBeanReference import org.springframework.beans.factory.support.AbstractBeanDefinition import org.springframework.beans.factory.support.BeanRegistryAdapter import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.context.aot.AbstractAotProcessor import org.springframework.core.env.StandardEnvironment import grails.core.GrailsApplication @@ -103,6 +104,50 @@ class UrlMappingsGrailsPluginSpec extends Specification { !registry.containsBeanDefinition('urlMappingsTargetSource') } + void "reloading is off while the code is being generated"() { + given: "surroundings that would otherwise reload" + def plugin = new Reloading() + + expect: "which is how the holder comes to be a proxy" + plugin.isReloadEnabled() + + when: "the same surroundings, while the code is being generated" + System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true') + + then: "the proxy produces its UrlMappings through a target source rather than declaring the " + + "type, so nothing could be autowired by that type from a generated definition" + !plugin.isReloadEnabled() + + cleanup: + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + } + + void "the surroundings still decide on an ordinary start"() { + given: + System.clearProperty(AbstractAotProcessor.AOT_PROCESSING) + + expect: "generation is the only thing that overrides them" + new Reloading().isReloadEnabled() + !new NotReloading().isReloadEnabled() + } + + /** Stands in for surroundings that reload, which a test JVM is not. */ + static class Reloading extends UrlMappingsGrailsPlugin { + + @Override + protected boolean isEnvironmentReloadable() { + true + } + } + + static class NotReloading extends UrlMappingsGrailsPlugin { + + @Override + protected boolean isEnvironmentReloadable() { + false + } + } + private static void applyRegistrar(DefaultListableBeanFactory beanFactory, GrailsApplication application) { def plugin = new UrlMappingsGrailsPlugin(grailsApplication: application) def registrar = plugin.beanRegistrar() diff --git a/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java new file mode 100644 index 00000000000..49e004e19cf --- /dev/null +++ b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.web.databinding.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the data binding API a request is bound through. + * + *

    Binding is reached only by a request that carries a body or parameters to bind, so a + * read-only walk of an application never records it and the absence shows up the first time + * a form is submitted.

    + * + * @since 8.0 + */ +public class DataBindingRuntimeHints implements RuntimeHintsRegistrar { + + /** + * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays + * correct for an application that does not use every plugin. + */ + private static final String[] DISPATCHED_TYPES = { + "grails.web.databinding.WebDataBinding", + "grails.web.databinding.DataBindingUtils", + "grails.databinding.DataBindingSource", + "grails.databinding.BindingHelper", + "grails.databinding.converters.ValueConverter", + // binding asks a target type whether it is an array, and Groovy makes that call + // reflectively on the Class object rather than directly + "java.lang.Class" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : DISPATCHED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } +} diff --git a/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..09045e2d0db --- /dev/null +++ b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.web.databinding.aot.DataBindingRuntimeHints diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java new file mode 100644 index 00000000000..14fd803f69a --- /dev/null +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.grails.web.mapping.aot; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * Registers the URL mapping API a request is dispatched through. + * + *

    Resolving a request reads the matched mapping reflectively through Groovy, so the + * declared methods of these types have to survive into the image. The parameter accessor in + * particular is reached only once a mapping carries parameters, which a walk of an + * application's pages need not do.

    + * + * @since 8.0 + */ +public class UrlMappingRuntimeHints implements RuntimeHintsRegistrar { + + /** + * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays + * correct for an application that does not use every plugin. + */ + private static final String[] DISPATCHED_TYPES = { + "grails.web.mapping.UrlMappingInfo", + "grails.web.mapping.UrlMapping", + "grails.web.mapping.UrlMappings", + "grails.web.mapping.UrlCreator", + "grails.web.mapping.LinkGenerator", + "grails.web.mapping.UrlMappingData" + }; + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + for (String type : DISPATCHED_TYPES) { + hints.reflection().registerTypeIfPresent(classLoader, type, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } +} diff --git a/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..f1d12f9db9c --- /dev/null +++ b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.grails.web.mapping.aot.UrlMappingRuntimeHints diff --git a/settings.gradle b/settings.gradle index ac25c7e025f..820ffbe9c3d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -526,6 +526,7 @@ project(':grails-test-examples-redis').projectDir = new File(settingsDir, 'grail // Functional Tests include( + 'grails-test-examples-aot', 'grails-test-examples-app1', 'grails-test-examples-app2', 'grails-test-examples-app3', @@ -573,6 +574,7 @@ include( project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo') project(':grails-test-examples-beans-dsl').projectDir = file('grails-test-examples/beans-dsl') project(':grails-test-examples-beans-dsl-plugin').projectDir = file('grails-test-examples/beans-dsl-plugin') +project(':grails-test-examples-aot').projectDir = file('grails-test-examples/aot') project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1') project(':grails-test-examples-app2').projectDir = file('grails-test-examples/app2') project(':grails-test-examples-app3').projectDir = file('grails-test-examples/app3')