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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ not the compatibility floor — override with `SpawnOptions.builder().version("X
sends (it reaches the engine as `MB_APIKEY`, not on the command line). `metricsPort(n)` moves the
engine's Prometheus listener off its default 9090, which two spawned engines would otherwise contend
for (the loser runs without metrics, and says so only in its log). `EmbeddedOptions` takes `metricsPort`, `allowInjection` and `requireAdminAuth` for the in-process
admin server the same way.
admin server the same way. Both take rift 0.22.0's body and journal limits too: `maxBodySize` (a
larger body gets `413`), `bodyMemoryBudget` (memory shared by buffered bodies; `503` past it) and
`journalMaxBytes` (recorded-request bytes per imposter; a larger request is recorded without its body,
`RecordedRequest.bodyOmitted()`), all in bytes.

### JUnit 5

Expand Down
8 changes: 8 additions & 0 deletions docs/design/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ public final class SpawnOptions {
// --metrics-port; unset = the engine's 9090, which two spawned engines contend for; a taken
// port is not fatal — the engine runs without metrics). An MB_APIKEY in env() is refused at
// build(), and one inherited from the JVM is not passed on.
// maxBodySize (long bytes >= 1, --max-body-size; over it 413), bodyMemoryBudget (>= 0, 0 = none,
// --body-memory-budget; over it 503 + Retry-After), journalMaxBytes (>= 1, --journal-max-bytes;
// a body larger than it is recorded without it, RecordedRequest.bodyOmitted()): rift >= 0.22.0,
// checked at build(), which also refuses a non-zero budget below the ceiling (the engine's
// 512 MiB default when unset), or a ceiling above its 1 GiB default budget with none set.
// Binary resolution order: binaryPath → $RIFT_BINARY_PATH → PATH (rift) →
// version cache (~/.cache/rift-java/binaries/rift-<ver>/) → download via release
// ffi-manifest.json (SHA-256 verified; RIFT_OFFLINE/RIFT_SKIP_BINARY_DOWNLOAD → fail).
Expand All @@ -213,6 +218,9 @@ public final class EmbeddedOptions {
// metricsPort (1..65535; unset = no metrics listener; serves the admin API eagerly, so a taken
// port fails Rift.embedded). Each needs the engine to advertise it in EngineInfo.serveOptions
// (rift >= 0.17.0), like upstreamTrust.
// maxBodySize / bodyMemoryBudget / journalMaxBytes (OptionalLong; as for SpawnOptions, checked at
// build(); advertised from rift 0.22.0; each serves the admin API eagerly, since the engine
// applies them only to imposters created afterwards).
}

public sealed interface UpstreamTrust { // outbound TLS trust for proxying/recording (#209)
Expand Down
9 changes: 9 additions & 0 deletions docs/testcontainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ static RiftContainer rift = new RiftContainer()
An image tagged with a version older than 0.18.0 is refused when `withUpstreamTrust` is called.
See [docs/recording.md](recording.md#recording-an-origin-behind-a-private-ca) for the details.

## Body and journal limits

`withMaxBodySize(bytes)`, `withBodyMemoryBudget(bytes)` and `withJournalMaxBytes(bytes)` set rift
0.22.0's limits through the engine's `RIFT_MAX_BODY_SIZE`, `RIFT_BODY_MEMORY_BUDGET` and
`RIFT_JOURNAL_MAX_BYTES`. A larger body gets `413`; buffered bodies past the budget get `503`
(`0` is no budget); a request larger than the journal budget is recorded without its body
(`RecordedRequest.bodyOmitted()`). An image tagged with a version older than 0.22.0 is refused, and a
non-zero budget below the body ceiling is refused when the container starts, as the engine would.

## Using it with the Spring module

Publish the container's admin URI as a property and point `@EnableRift(transport = CONNECT)` at it:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.github.achirdlabs.rift.conformance;

import io.github.achirdlabs.rift.Imposter;
import io.github.achirdlabs.rift.RecordedRequest;
import io.github.achirdlabs.rift.Rift;
import io.github.achirdlabs.rift.SpawnOptions;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.stream.Stream;

import static io.github.achirdlabs.rift.conformance.LiveEngine.gatedTo;
import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter;
import static io.github.achirdlabs.rift.dsl.RiftDsl.ok;
import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/** {@code SpawnOptions} body and journal limits reach a spawned rift 0.22.0 engine (#309). Needs {@code RIFT_IT=1}. */
class SpawnBodyLimitsIT {

private static final String EMBEDDED_COVERED = "the embedded limits are EmbeddedOptions', covered by LaunchOptionsIT";

@TestFactory
Stream<DynamicTest> maxBodySizeRefusesALargerBody() {
return gatedTo(ConformanceTransport.SPAWN, EMBEDDED_COVERED, "a body over maxBodySize gets 413", () -> {
try (Rift rift = Rift.spawn(SpawnOptions.builder().maxBodySize(1024).build())) {
int port = rift.create(imposter("capped").stub(onRequest().willReturn(ok()))).port();
assertEquals(413, post(port, 2048).statusCode());
assertEquals(200, post(port, 512).statusCode());
}
});
}

@TestFactory
Stream<DynamicTest> journalMaxBytesOmitsALargerBody() {
return gatedTo(ConformanceTransport.SPAWN, EMBEDDED_COVERED,
"a body larger than the journal budget is recorded without it", () -> {
try (Rift rift = Rift.spawn(SpawnOptions.builder().journalMaxBytes(4096).build())) {
Imposter imposter = rift.create(imposter("journal").record().stub(onRequest().willReturn(ok())));
assertEquals(200, post(imposter.port(), 16_384).statusCode());
RecordedRequest recorded = imposter.recorded().get(0);
assertTrue(recorded.bodyOmitted(), "recorded with its body (" + recorded.body().length() + " bytes)");
assertEquals("", recorded.body());
}
});
}

private static HttpResponse<Void> post(int port, int bodyBytes) throws Exception {
// a fresh client each time: the engine closes the connection after a 413
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
return client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/upload"))
.timeout(Duration.ofSeconds(5)).POST(HttpRequest.BodyPublishers.ofString("x".repeat(bodyBytes))).build(),
HttpResponse.BodyHandlers.discarding());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package io.github.achirdlabs.rift;

import java.util.OptionalLong;

/**
* The checks rift 0.22.0 applies to its body and journal limits (rift#1439, rift#1441), run where
* the SDK takes them so a bad combination fails at the builder rather than at engine start.
*/
final class BodyLimits {

static final String SINCE = "0.22.0";

/** The engine's {@code maxBodySize} when none is set: 512 MiB. */
static final long DEFAULT_MAX_BODY_SIZE = 512L << 20;

/** The engine's {@code bodyMemoryBudget} when none is set: 1 GiB. */
static final long DEFAULT_BUDGET = 1L << 30;

private BodyLimits() {
}

static long maxBodySize(long bytes) {
if (bytes < 1) {
throw new IllegalArgumentException("maxBodySize must be at least 1 byte, was " + bytes);
}
return bytes;
}

static long bodyMemoryBudget(long bytes) {
if (bytes < 0) {
throw new IllegalArgumentException("bodyMemoryBudget must be 0 (unbounded) or more bytes, was " + bytes);
}
return bytes;
}

static long journalMaxBytes(long bytes) {
if (bytes < 1) {
throw new IllegalArgumentException("journalMaxBytes must be at least 1 byte, was " + bytes);
}
return bytes;
}

/**
* The engine refuses a non-zero budget below the body ceiling, and fills in whichever of the two
* is unset with its own default (512 MiB ceiling, 1 GiB budget) before comparing them.
*/
static void requireBudgetCoversCeiling(OptionalLong maxBodySize, OptionalLong bodyMemoryBudget) {
if (bodyMemoryBudget.isPresent()) {
long budget = bodyMemoryBudget.getAsLong();
long ceiling = maxBodySize.orElse(DEFAULT_MAX_BODY_SIZE);
if (budget != 0 && budget < ceiling) {
throw new IllegalArgumentException(maxBodySize.isPresent()
? "bodyMemoryBudget (" + budget + ") is below maxBodySize (" + ceiling
+ "), which the engine refuses; raise it, or set 0 for no budget"
: "bodyMemoryBudget (" + budget + ") is below the engine's default maxBodySize (" + ceiling
+ "), which the engine refuses; raise it, lower maxBodySize, or set 0 for no budget");
}
} else if (maxBodySize.isPresent() && maxBodySize.getAsLong() > DEFAULT_BUDGET) {
throw new IllegalArgumentException("maxBodySize (" + maxBodySize.getAsLong() + ") is above the engine's "
+ "default bodyMemoryBudget (" + DEFAULT_BUDGET + "), which the engine refuses; set bodyMemoryBudget too");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;

/**
* Immutable configuration for {@link Rift#embedded(EmbeddedOptions)}: where to find the
Expand All @@ -27,6 +28,9 @@ public final class EmbeddedOptions {
private final Optional<Boolean> allowInjection;
private final Optional<Boolean> requireAdminAuth;
private final OptionalInt metricsPort;
private final OptionalLong maxBodySize;
private final OptionalLong bodyMemoryBudget;
private final OptionalLong journalMaxBytes;

private EmbeddedOptions(
Optional<Path> libraryPath,
Expand All @@ -38,7 +42,10 @@ private EmbeddedOptions(
Optional<UpstreamTrust> upstreamTrust,
Optional<Boolean> allowInjection,
Optional<Boolean> requireAdminAuth,
OptionalInt metricsPort) {
OptionalInt metricsPort,
OptionalLong maxBodySize,
OptionalLong bodyMemoryBudget,
OptionalLong journalMaxBytes) {
this.libraryPath = libraryPath;
this.versionCheck = versionCheck;
this.serveAdminEagerly = serveAdminEagerly;
Expand All @@ -49,6 +56,9 @@ private EmbeddedOptions(
this.allowInjection = allowInjection;
this.requireAdminAuth = requireAdminAuth;
this.metricsPort = metricsPort;
this.maxBodySize = maxBodySize;
this.bodyMemoryBudget = bodyMemoryBudget;
this.journalMaxBytes = journalMaxBytes;
}

public static Builder builder() {
Expand Down Expand Up @@ -101,6 +111,21 @@ public OptionalInt metricsPort() {
return metricsPort;
}

/** The largest body the engine accepts, if set; see {@link Builder#maxBodySize(long)}. */
public OptionalLong maxBodySize() {
return maxBodySize;
}

/** The memory buffered bodies may share, if set; see {@link Builder#bodyMemoryBudget(long)}. */
public OptionalLong bodyMemoryBudget() {
return bodyMemoryBudget;
}

/** Each imposter's recorded-request budget, if set; see {@link Builder#journalMaxBytes(long)}. */
public OptionalLong journalMaxBytes() {
return journalMaxBytes;
}

public static final class Builder {

private Optional<Path> libraryPath = Optional.empty();
Expand All @@ -113,6 +138,9 @@ public static final class Builder {
private Optional<Boolean> allowInjection = Optional.empty();
private Optional<Boolean> requireAdminAuth = Optional.empty();
private OptionalInt metricsPort = OptionalInt.empty();
private OptionalLong maxBodySize = OptionalLong.empty();
private OptionalLong bodyMemoryBudget = OptionalLong.empty();
private OptionalLong journalMaxBytes = OptionalLong.empty();

private Builder() {
}
Expand Down Expand Up @@ -277,9 +305,59 @@ public Builder metricsPort(int metricsPort) {
return this;
}

/**
* The largest request, proxied upstream response or admin body the engine accepts, in bytes;
* a larger one gets {@code 413} and the connection is closed. Unset, the engine's own 512 MiB
* applies; a value above 1 GiB needs {@link #bodyMemoryBudget(long)} too. The engine applies
* the limit only to imposters created after it is set, so setting it starts the in-process
* admin server when the engine starts, as {@link #serveAdminEagerly(boolean)} does. Needs an
* engine that advertises the option (rift &ge; 0.22.0), checked as for {@link
* #allowInjection(boolean)}.
*
* @throws IllegalArgumentException if {@code bytes} is below 1
*/
public Builder maxBodySize(long bytes) {
this.maxBodySize = OptionalLong.of(BodyLimits.maxBodySize(bytes));
return this;
}

/**
* The memory every body buffered at once may share, in bytes; past it a request gets {@code
* 503} with {@code Retry-After: 1}. {@code 0} is no budget. Unset, the engine's own 1 GiB
* applies; a non-zero budget must be at least {@link #maxBodySize(long)} (512 MiB when that is
* unset). Starts the admin
* server eagerly and is advertisement-checked, as {@code maxBodySize} is.
*
* @throws IllegalArgumentException if {@code bytes} is negative
*/
public Builder bodyMemoryBudget(long bytes) {
this.bodyMemoryBudget = OptionalLong.of(BodyLimits.bodyMemoryBudget(bytes));
return this;
}

/**
* How many bytes of recorded requests each imposter keeps, oldest evicted first. A request
* larger than the whole budget is recorded without its body: see {@link
* RecordedRequest#bodyOmitted()}. Unset, the engine's own 768 MiB applies. Starts the admin
* server eagerly and is advertisement-checked, as {@link #maxBodySize(long)} is.
*
* @throws IllegalArgumentException if {@code bytes} is below 1
*/
public Builder journalMaxBytes(long bytes) {
this.journalMaxBytes = OptionalLong.of(BodyLimits.journalMaxBytes(bytes));
return this;
}

/**
* @throws IllegalArgumentException if a non-zero {@link #bodyMemoryBudget(long)} is below
* {@link #maxBodySize(long)}, or a ceiling above 1 GiB has no
* budget: the engine refuses both
*/
public EmbeddedOptions build() {
BodyLimits.requireBudgetCoversCeiling(maxBodySize, bodyMemoryBudget);
return new EmbeddedOptions(libraryPath, versionCheck, serveAdminEagerly, adminHost, adminPort, apiKey,
upstreamTrust, allowInjection, requireAdminAuth, metricsPort);
upstreamTrust, allowInjection, requireAdminAuth, metricsPort, maxBodySize, bodyMemoryBudget,
journalMaxBytes);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.github.achirdlabs.rift.codec.BodyCodecs;
import io.github.achirdlabs.rift.codec.RiftBodyCodec;
import io.github.achirdlabs.rift.json.JsonArray;
import io.github.achirdlabs.rift.json.JsonBool;
import io.github.achirdlabs.rift.json.JsonNumber;
import io.github.achirdlabs.rift.json.JsonObject;
import io.github.achirdlabs.rift.json.JsonString;
Expand All @@ -23,6 +24,10 @@
* reads the same journal, filtered to its flow). Parsed leniently via {@link #read(JsonValue)}: any field
* the engine omits comes back empty rather than throwing, since the exact shape recorded requests
* take can vary by protocol and engine version.
*
* <p>{@code body} is {@code ""} for a request sent without one, and for one the engine recorded
* without its body because it was larger than the imposter's whole journal budget: {@link
* #bodyOmitted()} tells the two apart.
*/
public record RecordedRequest(
String method,
Expand All @@ -42,6 +47,15 @@ public record RecordedRequest(
pathParams = Map.copyOf(pathParams);
}

/**
* Whether the engine recorded this request without its body, because the body was larger than
* the imposter's whole journal budget ({@code journalMaxBytes}, rift &ge; 0.22.0); {@link #body()}
* is then {@code ""}. Read from the raw entry's {@code _bodyOmitted}.
*/
public boolean bodyOmitted() {
return raw instanceof JsonObject obj && obj.get("_bodyOmitted") instanceof JsonBool omitted && omitted.value();
}

/** Parses one {@code savedRequests} element. Never throws: unrecognized shapes come back mostly-empty. */
public static RecordedRequest read(JsonValue value) {
if (!(value instanceof JsonObject obj)) {
Expand Down
Loading
Loading