diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableException.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableException.java new file mode 100644 index 00000000000..71b1787cefc --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableException.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +/** + * Thrown when a bundle content location is not watchable. + * + * @author Moritz Halbritter + */ +class BundleContentNotWatchableException extends RuntimeException { + + private final BundleContentProperty property; + + BundleContentNotWatchableException(BundleContentProperty property) { + super("The content of '%s' is not watchable. Only 'file:' resources are watchable, but '%s' has been set" + .formatted(property.name(), property.value())); + this.property = property; + } + + private BundleContentNotWatchableException(String bundleName, BundleContentProperty property, Throwable cause) { + super("The content of '%s' from bundle '%s' is not watchable'. Only 'file:' resources are watchable, but '%s' has been set" + .formatted(property.name(), bundleName, property.value()), cause); + this.property = property; + } + + BundleContentNotWatchableException withBundleName(String bundleName) { + return new BundleContentNotWatchableException(bundleName, this.property, this); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzer.java new file mode 100644 index 00000000000..a41d9eabb3a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +/** + * An {@link AbstractFailureAnalyzer} that performs analysis of non-watchable bundle + * content failures caused by {@link BundleContentNotWatchableException}. + * + * @author Moritz Halbritter + */ +class BundleContentNotWatchableFailureAnalyzer extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, BundleContentNotWatchableException cause) { + return new FailureAnalysis(cause.getMessage(), "Update your application to correct the invalid configuration:\n" + + "Either use a watchable resource, or disable bundle reloading by setting reload-on-update = false on the bundle.", + cause); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java index 7be8e3eceb3..80a8343238f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java @@ -32,6 +32,7 @@ import org.springframework.util.StringUtils; * @param name the configuration property name (excluding any prefix) * @param value the configuration property value * @author Phillip Webb + * @author Moritz Halbritter */ record BundleContentProperty(String name, String value) { @@ -52,16 +53,17 @@ record BundleContentProperty(String name, String value) { } Path toWatchPath() { - return toPath(); - } - - private Path toPath() { try { URL url = toUrl(); - Assert.state(isFileUrl(url), () -> "Value '%s' is not a file URL".formatted(url)); + if (!isFileUrl(url)) { + throw new BundleContentNotWatchableException(this); + } return Path.of(url.toURI()).toAbsolutePath(); } catch (Exception ex) { + if (ex instanceof BundleContentNotWatchableException bundleContentNotWatchableException) { + throw bundleContentNotWatchableException; + } throw new IllegalStateException("Unable to convert value of property '%s' to a path".formatted(this.name), ex); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java index 583702c82ce..967c2ecdf85 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java @@ -54,13 +54,14 @@ class SslPropertiesBundleRegistrar implements SslBundleRegistrar { } private

void registerBundles(SslBundleRegistry registry, Map properties, - Function bundleFactory, Function> watchedPaths) { + Function bundleFactory, Function, Set> watchedPaths) { properties.forEach((bundleName, bundleProperties) -> { Supplier bundleSupplier = () -> bundleFactory.apply(bundleProperties); try { registry.registerBundle(bundleName, bundleSupplier.get()); if (bundleProperties.isReloadOnUpdate()) { - Supplier> pathsSupplier = () -> watchedPaths.apply(bundleProperties); + Supplier> pathsSupplier = () -> watchedPaths + .apply(new Bundle<>(bundleName, bundleProperties)); watchForUpdates(registry, bundleName, pathsSupplier, bundleSupplier); } } @@ -80,27 +81,40 @@ class SslPropertiesBundleRegistrar implements SslBundleRegistrar { } } - private Set watchedJksPaths(JksSslBundleProperties properties) { + private Set watchedJksPaths(Bundle bundle) { List watched = new ArrayList<>(); - watched.add(new BundleContentProperty("keystore.location", properties.getKeystore().getLocation())); - watched.add(new BundleContentProperty("truststore.location", properties.getTruststore().getLocation())); - return watchedPaths(watched); + watched.add(new BundleContentProperty("keystore.location", bundle.properties().getKeystore().getLocation())); + watched + .add(new BundleContentProperty("truststore.location", bundle.properties().getTruststore().getLocation())); + return watchedPaths(bundle.name(), watched); } - private Set watchedPemPaths(PemSslBundleProperties properties) { + private Set watchedPemPaths(Bundle bundle) { List watched = new ArrayList<>(); - watched.add(new BundleContentProperty("keystore.private-key", properties.getKeystore().getPrivateKey())); - watched.add(new BundleContentProperty("keystore.certificate", properties.getKeystore().getCertificate())); - watched.add(new BundleContentProperty("truststore.private-key", properties.getTruststore().getPrivateKey())); - watched.add(new BundleContentProperty("truststore.certificate", properties.getTruststore().getCertificate())); - return watchedPaths(watched); + watched + .add(new BundleContentProperty("keystore.private-key", bundle.properties().getKeystore().getPrivateKey())); + watched + .add(new BundleContentProperty("keystore.certificate", bundle.properties().getKeystore().getCertificate())); + watched.add(new BundleContentProperty("truststore.private-key", + bundle.properties().getTruststore().getPrivateKey())); + watched.add(new BundleContentProperty("truststore.certificate", + bundle.properties().getTruststore().getCertificate())); + return watchedPaths(bundle.name(), watched); } - private Set watchedPaths(List properties) { - return properties.stream() - .filter(BundleContentProperty::hasValue) - .map(BundleContentProperty::toWatchPath) - .collect(Collectors.toSet()); + private Set watchedPaths(String bundleName, List properties) { + try { + return properties.stream() + .filter(BundleContentProperty::hasValue) + .map(BundleContentProperty::toWatchPath) + .collect(Collectors.toSet()); + } + catch (BundleContentNotWatchableException ex) { + throw ex.withBundleName(bundleName); + } + } + + private record Bundle

(String name, P properties) { } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 3accbafa226..6c4f3a005be 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -31,7 +31,8 @@ org.springframework.boot.autoconfigure.jooq.NoDslContextBeanFailureAnalyzer,\ org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryBeanCreationFailureAnalyzer,\ org.springframework.boot.autoconfigure.r2dbc.MissingR2dbcPoolDependencyFailureAnalyzer,\ org.springframework.boot.autoconfigure.r2dbc.MultipleConnectionPoolConfigurationsFailureAnalyzer,\ -org.springframework.boot.autoconfigure.r2dbc.NoConnectionFactoryBeanFailureAnalyzer +org.springframework.boot.autoconfigure.r2dbc.NoConnectionFactoryBeanFailureAnalyzer,\ +org.springframework.boot.autoconfigure.ssl.BundleContentNotWatchableFailureAnalyzer # Template Availability Providers org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider=\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzerTests.java new file mode 100644 index 00000000000..fdaf0bda837 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentNotWatchableFailureAnalyzerTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.diagnostics.FailureAnalysis; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link BundleContentNotWatchableFailureAnalyzer}. + * + * @author Moritz Halbritter + */ +class BundleContentNotWatchableFailureAnalyzerTests { + + @Test + void shouldAnalyze() { + FailureAnalysis failureAnalysis = performAnalysis(null); + assertThat(failureAnalysis.getDescription()).isEqualTo( + "The content of 'name' is not watchable. Only 'file:' resources are watchable, but 'classpath:resource.pem' has been set"); + assertThat(failureAnalysis.getAction()) + .isEqualTo("Update your application to correct the invalid configuration:\n" + + "Either use a watchable resource, or disable bundle reloading by setting reload-on-update = false on the bundle."); + } + + @Test + void shouldAnalyzeWithBundle() { + FailureAnalysis failureAnalysis = performAnalysis("bundle-1"); + assertThat(failureAnalysis.getDescription()).isEqualTo( + "The content of 'name' from bundle 'bundle-1' is not watchable'. Only 'file:' resources are watchable, but 'classpath:resource.pem' has been set"); + } + + private FailureAnalysis performAnalysis(String bundle) { + BundleContentNotWatchableException failure = new BundleContentNotWatchableException( + new BundleContentProperty("name", "classpath:resource.pem")); + if (bundle != null) { + failure = failure.withBundleName(bundle); + } + return new BundleContentNotWatchableFailureAnalyzer().analyze(failure); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java index 72d5f6ae319..3598d00b304 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** @@ -84,4 +85,11 @@ class BundleContentPropertyTests { assertThat(property.toWatchPath()).isEqualTo(file); } + @Test + void shouldThrowBundleContentNotWatchableExceptionIfContentIsNotWatchable() { + BundleContentProperty property = new BundleContentProperty("name", "https://example.com/"); + assertThatExceptionOfType(BundleContentNotWatchableException.class).isThrownBy(property::toWatchPath) + .withMessageContaining("Only 'file:' resources are watchable"); + } + }