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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ register. With the property unset no `ObjectStore` bean exists at all.
```properties
# S3 or any S3-compatible endpoint
openelements.storage.type=s3
openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com # all five required for type=s3
openelements.storage.s3.region=eu-central-1
openelements.storage.s3.bucket=my-objects
openelements.storage.s3.access-key=${S3_ACCESS_KEY}
openelements.storage.s3.secret-key=${S3_SECRET_KEY}
openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com # required for type=s3
openelements.storage.s3.bucket=my-objects # required
openelements.storage.s3.access-key=${S3_ACCESS_KEY} # required
openelements.storage.s3.secret-key=${S3_SECRET_KEY} # required
openelements.storage.s3.region=eu-central-1 # optional, default us-east-1

# …or a local directory
openelements.storage.type=file
Expand Down
15 changes: 10 additions & 5 deletions docs/releases/upgrade-to-1.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,21 @@ distinguishes them — and classpath order must not be what decides where your o
```properties
openelements.storage.type=s3
openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com
openelements.storage.s3.region=eu-central-1
openelements.storage.s3.bucket=my-objects
openelements.storage.s3.access-key=${S3_ACCESS_KEY}
openelements.storage.s3.secret-key=${S3_SECRET_KEY}

# Optional. Defaults to us-east-1, which providers that ignore the region accept.
openelements.storage.s3.region=eu-central-1
```

All five are required for `type=s3`. Credentials are high-value secrets — provide them from
environment variables or a secret manager, never in plaintext config. Path-style addressing is forced
and chunked encoding is disabled, because several S3-compatible providers support neither
virtual-host addressing nor trailing checksums.
Endpoint, bucket and credentials are required for `type=s3`. The **region is optional**: it belongs
to the request *signature*, not to the address, so SigV4 needs some string there but only AWS derives
meaning from it — on AWS it must match the bucket's region, while Hetzner Object Storage and similar
providers accept whatever is sent. Credentials are high-value secrets — provide them from environment
variables or a secret manager, never in plaintext config. Path-style addressing is forced and chunked
encoding is disabled, because several S3-compatible providers support neither virtual-host addressing
nor trailing checksums.

**A local directory:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

/**
* Configuration for the object store, bound from the {@code openelements.storage.*} namespace.
Expand Down Expand Up @@ -67,30 +68,43 @@ public File requiredFile() {
* Connection settings for an S3-compatible endpoint. Credentials belong in environment variables
* or a secret manager, never in a checked-in properties file.
*
* <p>Every component is required, and a missing one fails start-up rather than start-up
* succeeding with a store the application cannot actually write to.
* <p>Endpoint, bucket and credentials are required, and a missing one fails start-up rather than
* start-up succeeding with a store the application cannot actually write to. The region is the
* exception — see {@link #region()}.
*
* @param endpoint the endpoint URL, which is explicit because the module targets AWS S3 and
* other S3-compatible providers alike
* @param region the region to sign requests for
* @param region the region to sign requests for. It belongs to the <em>signature</em>, not to
* the address: SigV4 needs some region string, which is why there is a default,
* but only AWS derives meaning from it. Providers like Hetzner Object Storage
* accept whatever is sent, so configuring it there would be ceremony. Set it
* when your provider cares — on AWS it must match the bucket's region.
* @param bucket the bucket every key lives in
* @param accessKey the access key
* @param secretKey the secret key
*/
public record S3(String endpoint, String region, String bucket, String accessKey,
String secretKey) {
public record S3(String endpoint, @DefaultValue(DEFAULT_REGION) String region, String bucket,
String accessKey, String secretKey) {

/**
* Validates that nothing is missing.
* The region used when none is configured — the value S3-compatible providers conventionally
* accept and ignore.
*/
public static final String DEFAULT_REGION = "us-east-1";

/**
* Validates that nothing required is missing.
*
* @throws NullPointerException if any component was not configured
* @throws NullPointerException if a required component was not configured
*/
public S3 {
required(endpoint, "endpoint");
required(region, "region");
required(bucket, "bucket");
required(accessKey, "access-key");
required(secretKey, "secret-key");
// Not "required": the binder fills it from DEFAULT_REGION, so a null here means the
// record was constructed directly rather than bound, and Region.of would fail later.
Objects.requireNonNull(region, "region must not be null");
}

private static void required(final @Nullable String value, final String name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ void s3() {
.isInstanceOf(S3ObjectStore.class));
}

@Test
@DisplayName("type=s3 works without a region, which only AWS derives meaning from")
void s3WithoutARegion() {
contextRunner.withPropertyValues(
"openelements.storage.type=s3",
"openelements.storage.s3.endpoint=https://fsn1.your-objectstorage.com",
"openelements.storage.s3.bucket=objects",
"openelements.storage.s3.access-key=key",
"openelements.storage.s3.secret-key=secret")
.run(context -> {
assertThat(context).hasNotFailed().hasSingleBean(ObjectStore.class);
assertThat(context.getBean(StorageProperties.class).requiredS3().region())
.isEqualTo(StorageProperties.S3.DEFAULT_REGION);
});
}

@Test
@DisplayName("A configured region is kept, for the providers that do care")
void s3WithAnExplicitRegion() {
contextRunner.withPropertyValues(s3Properties())
.run(context -> assertThat(context).hasNotFailed()
.getBean(StorageProperties.class)
.extracting(properties -> properties.requiredS3().region())
.isEqualTo("eu-central-1"));
}

@Test
@DisplayName("Only the selected store is registered, not the other two")
void onlyTheSelectedOne() {
Expand Down Expand Up @@ -110,7 +136,7 @@ void s3WithIncompleteSettings() {
.run(context -> assertThat(context).hasFailed()
.getFailure()
.rootCause()
.hasMessageContaining("openelements.storage.s3.region is required"));
.hasMessageContaining("openelements.storage.s3.bucket is required"));
}

@Test
Expand Down
Loading