From 9f09c861820477684ac35798c89628317a6f0cd8 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Mon, 23 Mar 2026 15:37:10 +0100 Subject: [PATCH 01/10] Fix test-service-broker crash on JDK 17 by adding --add-opens flags The test-service-broker.jar (Spring Boot 1.5.16 / Spring Framework 4.3.19) uses CGLIB proxying which requires reflective access to java.lang.ClassLoader.defineClass(). JDK 17's module system blocks this, causing the app to crash on startup with InaccessibleObjectException and all ApplicationsTest methods to fail with DelayTimeoutException during serviceBrokerId bean creation. --- .../src/test/java/org/cloudfoundry/ServiceBrokerUtils.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integration-test/src/test/java/org/cloudfoundry/ServiceBrokerUtils.java b/integration-test/src/test/java/org/cloudfoundry/ServiceBrokerUtils.java index e3677bd7ba..f4766314f3 100644 --- a/integration-test/src/test/java/org/cloudfoundry/ServiceBrokerUtils.java +++ b/integration-test/src/test/java/org/cloudfoundry/ServiceBrokerUtils.java @@ -145,6 +145,10 @@ public static Mono pushServiceBrokerApplic Map env = new HashMap<>(); env.put("SERVICE_NAME", serviceName); env.put("PLAN_NAME", planName); + env.put( + "JAVA_OPTS", + "--add-opens java.base/java.lang=ALL-UNNAMED" + + " --add-opens java.base/java.io=ALL-UNNAMED"); return ApplicationUtils.pushApplication( cloudFoundryClient, From 1a1beebde5968bbb8a00ca245a80dc30d677acaa Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Tue, 17 Feb 2026 17:20:47 +0100 Subject: [PATCH 02/10] Support JWT GrantType --- .../reactor/uaa/clients/ReactorClientsTest.java | 2 ++ .../resources/fixtures/uaa/clients/GET_{id}_response.json | 3 ++- .../main/java/org/cloudfoundry/uaa/tokens/GrantType.java | 7 +++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/clients/ReactorClientsTest.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/clients/ReactorClientsTest.java index c36ac4f443..a9d730008a 100644 --- a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/clients/ReactorClientsTest.java +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/clients/ReactorClientsTest.java @@ -24,6 +24,7 @@ import static io.netty.handler.codec.http.HttpResponseStatus.OK; import static org.cloudfoundry.uaa.tokens.GrantType.AUTHORIZATION_CODE; import static org.cloudfoundry.uaa.tokens.GrantType.CLIENT_CREDENTIALS; +import static org.cloudfoundry.uaa.tokens.GrantType.JWT_BEARER; import static org.cloudfoundry.uaa.tokens.GrantType.REFRESH_TOKEN; import java.time.Duration; @@ -620,6 +621,7 @@ void get() { .allowedProviders("uaa", "ldap", "my-saml-provider") .authorities("clients.read", "clients.write") .authorizedGrantType(CLIENT_CREDENTIALS) + .authorizedGrantType(JWT_BEARER) .autoApprove("true") .clientId("4Z3t1r") .lastModified(1468364445592L) diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/clients/GET_{id}_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/clients/GET_{id}_response.json index d8b2187e47..2a05df3d70 100644 --- a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/clients/GET_{id}_response.json +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/clients/GET_{id}_response.json @@ -8,7 +8,8 @@ "none" ], "authorized_grant_types": [ - "client_credentials" + "client_credentials", + "urn:ietf:params:oauth:grant-type:jwt-bearer" ], "redirect_uri": [ "http*://ant.path.wildcard/**/passback/*", diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/tokens/GrantType.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/tokens/GrantType.java index 6dd626c52d..0d84a4b238 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/tokens/GrantType.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/tokens/GrantType.java @@ -39,6 +39,11 @@ public enum GrantType { */ IMPLICIT("implicit"), + /** + * The JWT bearer grant type + */ + JWT_BEARER("urn:ietf:params:oauth:grant-type:jwt-bearer"), + /** * The password grant type */ @@ -68,6 +73,8 @@ public static GrantType from(String s) { return PASSWORD; case "refresh_token": return REFRESH_TOKEN; + case "urn:ietf:params:oauth:grant-type:jwt-bearer": + return JWT_BEARER; default: throw new IllegalArgumentException(String.format("Unknown grant type: %s", s)); } From a1f5541f36cf723270c35c0e5ff48b87888da198 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Tue, 17 Feb 2026 17:21:21 +0100 Subject: [PATCH 03/10] Support default identity provider property --- .../uaa/identityzones/ReactorIdentityZonesTest.java | 2 ++ .../fixtures/uaa/identity-zones/GET_response.json | 1 + .../uaa/identityzones/_IdentityZoneConfiguration.java | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/identityzones/ReactorIdentityZonesTest.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/identityzones/ReactorIdentityZonesTest.java index 62674816d2..f0a5a60d1f 100644 --- a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/identityzones/ReactorIdentityZonesTest.java +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/identityzones/ReactorIdentityZonesTest.java @@ -832,6 +832,8 @@ void list() { + " /passcode)") .build()) .ldapDiscoveryEnabled(false) + .defaultIdentityProvider( + "test-identity-provider") .accountChooserEnabled(false) .build()) .name("The Twiglet Zone") diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/identity-zones/GET_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/identity-zones/GET_response.json index a5980a1815..817cb18927 100644 --- a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/identity-zones/GET_response.json +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/identity-zones/GET_response.json @@ -97,6 +97,7 @@ "text": "One Time Code (Get on at /passcode)" } ], + "defaultIdentityProvider": "test-identity-provider", "idpDiscoveryEnabled": false, "accountChooserEnabled": false }, diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityzones/_IdentityZoneConfiguration.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityzones/_IdentityZoneConfiguration.java index 0c98a54a24..a3cc988642 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityzones/_IdentityZoneConfiguration.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityzones/_IdentityZoneConfiguration.java @@ -65,6 +65,13 @@ abstract class _IdentityZoneConfiguration { @Nullable abstract CorsPolicy getCorsPolicy(); + /** + * The default identity provider for this zone + */ + @JsonProperty("defaultIdentityProvider") + @Nullable + abstract String getDefaultIdentityProvider(); + /** * The issuer of this zone */ From 10e8c1a8b6dbda355357fc2a3de5d51d197c9048 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Mon, 23 Feb 2026 16:19:06 +0100 Subject: [PATCH 04/10] Accept "access_denied: Access Denied" from UAA Some UAA seem to send a slightly different reply. This accepts and tests for both. --- .../src/test/java/org/cloudfoundry/uaa/TokensTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-test/src/test/java/org/cloudfoundry/uaa/TokensTest.java b/integration-test/src/test/java/org/cloudfoundry/uaa/TokensTest.java index 40b02850f0..39d956f801 100644 --- a/integration-test/src/test/java/org/cloudfoundry/uaa/TokensTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/uaa/TokensTest.java @@ -84,7 +84,7 @@ public void checkTokenNotAuthorized() { t -> assertThat(t) .isInstanceOf(UaaException.class) - .hasMessage("access_denied: Access is denied")) + .hasMessageContainingAll("access_denied", "Access")) .verify(Duration.ofMinutes(5)); } From a3558e6f1cf36d772a13097367f667c8aea2b1d0 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Mon, 23 Feb 2026 16:27:49 +0100 Subject: [PATCH 05/10] Support defaultIdpName of newer UAA See https://github.com/cloudfoundry/uaa/blob/284dd502db2316bfbdb05c47d9a6d23e3c82ba6a/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginInfoEndpoint.java#L277 --- .../serverinformation/ReactorServerInformationTest.java | 1 + .../test/resources/fixtures/uaa/info/GET_response.json | 3 ++- .../uaa/serverinformation/_GetInfoResponse.java | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/serverinformation/ReactorServerInformationTest.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/serverinformation/ReactorServerInformationTest.java index a0e921ef75..e49bb077a8 100644 --- a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/serverinformation/ReactorServerInformationTest.java +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/uaa/serverinformation/ReactorServerInformationTest.java @@ -151,6 +151,7 @@ void getInfo() { .showLoginLinks(true) .timestamp("2017-09-08T23:11:58+0000") .zoneName("uaa") + .defaultIdpName("test-idp-name") .build()) .expectComplete() .verify(Duration.ofSeconds(5)); diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/info/GET_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/info/GET_response.json index 6c6784819c..2d681f7b5f 100644 --- a/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/info/GET_response.json +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/uaa/info/GET_response.json @@ -30,5 +30,6 @@ "One Time Code ( Get one at http://localhost:8080/uaa/passcode )" ] }, - "timestamp": "2017-09-08T23:11:58+0000" + "timestamp": "2017-09-08T23:11:58+0000", + "defaultIdpName": "test-idp-name" } \ No newline at end of file diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/serverinformation/_GetInfoResponse.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/serverinformation/_GetInfoResponse.java index 018661777a..4b571ac709 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/serverinformation/_GetInfoResponse.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/serverinformation/_GetInfoResponse.java @@ -93,4 +93,12 @@ abstract class _GetInfoResponse { @Nullable abstract String getZoneName(); + /** + * The default identity provider name + */ + @JsonProperty("defaultIdpName") + @Nullable + abstract String getDefaultIdpName(); + + } From eaf1fa6fd9c48c5b22f31380f53b66e38b5fc5d7 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Mon, 23 Feb 2026 16:42:07 +0100 Subject: [PATCH 06/10] Support uaa setups without password reset endpoint Tests login endpoint instead, which should usually be present --- .../java/org/cloudfoundry/uaa/ServerInformationTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/integration-test/src/test/java/org/cloudfoundry/uaa/ServerInformationTest.java b/integration-test/src/test/java/org/cloudfoundry/uaa/ServerInformationTest.java index f12fa616dc..fb1298c916 100644 --- a/integration-test/src/test/java/org/cloudfoundry/uaa/ServerInformationTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/uaa/ServerInformationTest.java @@ -95,13 +95,17 @@ public void getInfo() { this.uaaClient .serverInformation() .getInfo(GetInfoRequest.builder().build()) - .map(response -> response.getLinks().getPassword()) + .map(response -> response.getLinks().getLogin()) .as(StepVerifier::create) - .consumeNextWith(endsWithExpectation("password")) + .consumeNextWith(containsExpectation("login")) .expectComplete() .verify(Duration.ofMinutes(5)); } + private static Consumer containsExpectation(String substring) { + return actual -> assertThat(actual).contains(substring); + } + private static Consumer endsWithExpectation(String suffix) { return actual -> assertThat(actual).endsWith(suffix); } From 3c9a63f13757d23d1299b2e36d4811d0c291198c Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Fri, 27 Mar 2026 18:16:49 +0100 Subject: [PATCH 07/10] feat: Replace deprecated Doppler recentLogs with Log Cache client (#1348) See gh-1237, gh-1348 --- README.md | 41 ++++++++ .../cloudfoundry/doppler/DopplerClient.java | 8 +- .../_DefaultCloudFoundryOperations.java | 16 ++- .../operations/applications/Applications.java | 5 +- .../applications/DefaultApplications.java | 97 +++++++++++++++---- .../operations/AbstractOperationsTest.java | 3 + .../applications/DefaultApplicationsTest.java | 87 +++++++++++++++-- .../IntegrationTestConfiguration.java | 3 + .../operations/ApplicationsTest.java | 56 +---------- 9 files changed, 234 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 6f6e77f4ae..63c7686b42 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,44 @@ The `cf-java-client` project is a Java language binding for interacting with a C ## Versions The Cloud Foundry Java Client has two active versions. The `5.x` line is compatible with Spring Boot `2.4.x - 2.6.x` just to manage its dependencies, while the `4.x` line uses Spring Boot `2.3.x`. +## Deprecations + +### `DopplerClient.recentLogs()` — Recent Logs via Doppler + +> [!WARNING] +> **Deprecated since cf-java-client `5.17.x`** +> +> The `DopplerClient.recentLogs()` endpoint (and the related `RecentLogsRequest` / `LogMessage` types from the `org.cloudfoundry.doppler` package) are **deprecated** and will be removed in a future release. +> +> This API relies on the [Loggregator][loggregator] Doppler/Traffic Controller endpoint `/apps/{id}/recentlogs`, which was removed in **Loggregator ≥ 107.0**. +> The affected platform versions are: +> +> | Platform | Last version with Doppler recent-logs support | +> | -------- | --------------------------------------------- | +> | CF Deployment (CFD) | `< 24.3` | +> | Tanzu Application Service (TAS) | `< 4.0` | +> +> **Migration:** Replace any call to `DopplerClient.recentLogs()` with [`LogCacheClient.read()`][log-cache-api] (available via `org.cloudfoundry.logcache.v1.LogCacheClient`). +> +> ```java +> // Before (deprecated) +> dopplerClient.recentLogs(RecentLogsRequest.builder() +> .applicationId(appId) +> .build()); +> +> // After +> logCacheClient.read(ReadRequest.builder() +> .sourceId(appId) +> .envelopeTypes(EnvelopeType.LOG) +> .build()); +> ``` + +> [!NOTE] +> **Operations API users:** `Applications.logs(ApplicationLogsRequest)` now uses Log Cache under the hood for recent logs (the default). No migration is needed at the Operations layer. + +[loggregator]: https://github.com/cloudfoundry/loggregator +[log-cache-api]: https://github.com/cloudfoundry/log-cache + ## Dependencies Most projects will need two dependencies; the Operations API and an implementation of the Client API. For Maven, the dependencies would be defined like this: @@ -76,6 +114,9 @@ Both the `cloudfoundry-operations` and `cloudfoundry-client` projects follow a [ ### `CloudFoundryClient`, `DopplerClient`, `UaaClient` Builders +> [!NOTE] +> **`DopplerClient` — partial deprecation:** The `recentLogs()` method on `DopplerClient` is deprecated and only works against Loggregator \< 107.0 (CFD \< 24.3 / TAS \< 4.0). See the [Deprecations](#deprecations) section above for the migration path to `LogCacheClient`. + The lowest-level building blocks of the API are `ConnectionContext` and `TokenProvider`. These types are intended to be shared between instances of the clients, and come with out of the box implementations. To instantiate them, you configure them with builders: ```java diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/doppler/DopplerClient.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/doppler/DopplerClient.java index a9c03441cf..3d61922734 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/doppler/DopplerClient.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/doppler/DopplerClient.java @@ -42,9 +42,15 @@ public interface DopplerClient { /** * Makes the Recent Logs request * + * @deprecated Use {@link org.cloudfoundry.logcache.v1.LogCacheClient#read(org.cloudfoundry.logcache.v1.ReadRequest)} instead. + * Only works with {@code Loggregator < 107.0}, shipped in {@code CFD < 24.3} and {@code TAS < 4.0}. * @param request the Recent Logs request - * @return the events from the recent logs + * @return a flux of events from the recent logs + * @see Loggregator + * @see Log Cache + * @see org.cloudfoundry.logcache.v1.LogCacheClient#read(org.cloudfoundry.logcache.v1.ReadRequest) */ + @Deprecated Flux recentLogs(RecentLogsRequest request); /** diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/_DefaultCloudFoundryOperations.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/_DefaultCloudFoundryOperations.java index 1d21f29b2b..4db0bd8489 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/_DefaultCloudFoundryOperations.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/_DefaultCloudFoundryOperations.java @@ -23,6 +23,7 @@ import org.cloudfoundry.client.v3.spaces.ListSpacesRequest; import org.cloudfoundry.client.v3.spaces.SpaceResource; import org.cloudfoundry.doppler.DopplerClient; +import org.cloudfoundry.logcache.v1.LogCacheClient; import org.cloudfoundry.networking.NetworkingClient; import org.cloudfoundry.operations.advanced.Advanced; import org.cloudfoundry.operations.advanced.DefaultAdvanced; @@ -79,7 +80,7 @@ public Advanced advanced() { @Override @Value.Derived public Applications applications() { - return new DefaultApplications(getCloudFoundryClientPublisher(), getDopplerClientPublisher(), getSpaceId()); + return new DefaultApplications(getCloudFoundryClientPublisher(), getDopplerClientPublisher(), getLogCacheClientPublisher(), getSpaceId()); } @Override @@ -197,6 +198,19 @@ Mono getDopplerClientPublisher() { .orElse(Mono.error(new IllegalStateException("DopplerClient must be set"))); } + /** + * The {@link LogCacheClient} to use for operations functionality + */ + @Nullable + abstract LogCacheClient getLogCacheClient(); + + @Value.Derived + Mono getLogCacheClientPublisher() { + return Optional.ofNullable(getLogCacheClient()) + .map(Mono::just) + .orElse(Mono.error(new IllegalStateException("LogCacheClient must be set"))); + } + /** * The {@link NetworkingClient} to use for operations functionality */ diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/Applications.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/Applications.java index 5196fef6c8..56aba6af64 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/Applications.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/Applications.java @@ -128,8 +128,9 @@ public interface Applications { /** * List the applications logs. - * Only works with {@code Loggregator < 107.0}, shipped in {@code CFD < 24.3} - * and {@code TAS < 4.0}. + * Uses Log Cache under the hood when {@link ApplicationLogsRequest#getRecent()} is {@code true}. + * Log streaming still uses Doppler, which is not available in CF deployments following + * shared-nothing architecture. * * @param request the application logs request * @return the applications logs diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java index e51ddbb472..bb492fffe7 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java @@ -154,6 +154,9 @@ import org.cloudfoundry.doppler.LogMessage; import org.cloudfoundry.doppler.RecentLogsRequest; import org.cloudfoundry.doppler.StreamRequest; +import org.cloudfoundry.logcache.v1.EnvelopeBatch; +import org.cloudfoundry.logcache.v1.LogCacheClient; +import org.cloudfoundry.logcache.v1.ReadRequest; import org.cloudfoundry.operations.util.OperationsLogging; import org.cloudfoundry.util.DateUtils; import org.cloudfoundry.util.DelayTimeoutException; @@ -200,6 +203,10 @@ public final class DefaultApplications implements Applications { private static final Comparator LOG_MESSAGE_COMPARATOR = Comparator.comparing(LogMessage::getTimestamp); + private static final Comparator + LOG_MESSAGE_COMPARATOR_LOG_CACHE = + Comparator.comparing(org.cloudfoundry.logcache.v1.Envelope::getTimestamp); + private static final Duration LOG_MESSAGE_TIMESPAN = Duration.ofMillis(500); private static final int MAX_NUMBER_OF_RECENT_EVENTS = 50; @@ -214,6 +221,8 @@ public final class DefaultApplications implements Applications { private final Mono dopplerClient; + private final Mono logCacheClient; + private final RandomWords randomWords; private final Mono spaceId; @@ -221,17 +230,20 @@ public final class DefaultApplications implements Applications { public DefaultApplications( Mono cloudFoundryClient, Mono dopplerClient, + Mono logCacheClient, Mono spaceId) { - this(cloudFoundryClient, dopplerClient, new WordListRandomWords(), spaceId); + this(cloudFoundryClient, dopplerClient, logCacheClient, new WordListRandomWords(), spaceId); } DefaultApplications( Mono cloudFoundryClient, Mono dopplerClient, + Mono logCacheClient, RandomWords randomWords, Mono spaceId) { this.cloudFoundryClient = cloudFoundryClient; this.dopplerClient = dopplerClient; + this.logCacheClient = logCacheClient; this.randomWords = randomWords; this.spaceId = spaceId; } @@ -529,6 +541,7 @@ public Flux listTasks(ListApplicationTasksRequest request) { .checkpoint(); } + @Deprecated @Override public Flux logs(LogsRequest request) { return Mono.zip(this.cloudFoundryClient, this.spaceId) @@ -546,22 +559,23 @@ public Flux logs(LogsRequest request) { @Override public Flux logs(ApplicationLogsRequest request) { - return logs(LogsRequest.builder() - .name(request.getName()) - .recent(request.getRecent()) - .build()) - .map( - logMessage -> - ApplicationLog.builder() - .sourceId(logMessage.getApplicationId()) - .sourceType(logMessage.getSourceType()) - .instanceId(logMessage.getSourceInstance()) - .message(logMessage.getMessage()) - .timestamp(logMessage.getTimestamp()) - .logType( - ApplicationLogType.from( - logMessage.getMessageType().name())) - .build()); + if (request.getRecent() == null || request.getRecent()) { + return Mono.zip(this.cloudFoundryClient, this.spaceId) + .flatMap( + function( + (cloudFoundryClient, spaceId) -> + getApplicationId( + cloudFoundryClient, + request.getName(), + spaceId))) + .flatMapMany( + applicationId -> getLogsLogCache(this.logCacheClient, applicationId)) + .transform(OperationsLogging.log("Get Application Logs")) + .checkpoint(); + } else { + return logs(LogsRequest.builder().name(request.getName()).recent(false).build()) + .map(DefaultApplications::toApplicationLog); + } } @Override @@ -673,7 +687,6 @@ public Mono pushManifestV3(PushManifestV3Request request) { } catch (IOException e) { throw new RuntimeException("Could not serialize manifest", e); } - return Mono.zip(this.cloudFoundryClient, this.spaceId) .flatMap( function( @@ -1617,6 +1630,32 @@ private static Flux getLogs( } } + private static Flux getLogsLogCache( + Mono logCacheClient, String applicationId) { + return requestLogsRecentLogCache(logCacheClient, applicationId) + .filter(e -> e.getLog() != null) + .sort(LOG_MESSAGE_COMPARATOR_LOG_CACHE) + .map( + envelope -> + ApplicationLog.builder() + .sourceId( + Optional.ofNullable(envelope.getSourceId()) + .orElse("")) + .sourceType( + envelope.getTags().getOrDefault("source_type", "")) + .instanceId( + Optional.ofNullable(envelope.getInstanceId()) + .orElse("")) + .message(envelope.getLog().getPayloadAsText()) + .timestamp( + Optional.ofNullable(envelope.getTimestamp()) + .orElse(0L)) + .logType( + ApplicationLogType.from( + envelope.getLog().getType().name())) + .build()); + } + @SuppressWarnings("unchecked") private static Map getMetadataRequest(EventEntity entity) { Map> metadata = @@ -2501,6 +2540,7 @@ private static Flux requestListTasks( .build())); } + @Deprecated private static Flux requestLogsRecent( Mono dopplerClient, String applicationId) { return dopplerClient.flatMapMany( @@ -2509,6 +2549,16 @@ private static Flux requestLogsRecent( RecentLogsRequest.builder().applicationId(applicationId).build())); } + private static Flux requestLogsRecentLogCache( + Mono logCacheClient, String applicationId) { + return logCacheClient + .flatMap( + client -> + client.read(ReadRequest.builder().sourceId(applicationId).build())) + .flatMap(response -> Mono.justOrEmpty(response.getEnvelopes())) + .flatMapIterable(EnvelopeBatch::getBatch); + } + private static Flux requestLogsStream( Mono dopplerClient, String applicationId) { return dopplerClient.flatMapMany( @@ -2914,6 +2964,17 @@ private static Mono stopApplicationIfNotStopped( : Mono.just(resource); } + private static ApplicationLog toApplicationLog(LogMessage logMessage) { + return ApplicationLog.builder() + .sourceId(logMessage.getApplicationId()) + .sourceType(logMessage.getSourceType()) + .instanceId(logMessage.getSourceInstance()) + .message(logMessage.getMessage()) + .timestamp(logMessage.getTimestamp()) + .logType(ApplicationLogType.from(logMessage.getMessageType().name())) + .build(); + } + private static ApplicationDetail toApplicationDetail( List buildpacks, SummaryApplicationResponse summaryApplicationResponse, diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/AbstractOperationsTest.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/AbstractOperationsTest.java index ab1250658a..9dd97126e8 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/AbstractOperationsTest.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/AbstractOperationsTest.java @@ -53,6 +53,7 @@ import org.cloudfoundry.client.v3.stacks.StacksV3; import org.cloudfoundry.client.v3.tasks.Tasks; import org.cloudfoundry.doppler.DopplerClient; +import org.cloudfoundry.logcache.v1.LogCacheClient; import org.cloudfoundry.routing.RoutingClient; import org.cloudfoundry.routing.v1.routergroups.RouterGroups; import org.cloudfoundry.uaa.UaaClient; @@ -104,6 +105,8 @@ public abstract class AbstractOperationsTest { protected final DopplerClient dopplerClient = mock(DopplerClient.class, RETURNS_SMART_NULLS); + protected final LogCacheClient logCacheClient = mock(LogCacheClient.class, RETURNS_SMART_NULLS); + protected final Events events = mock(Events.class, RETURNS_SMART_NULLS); protected final FeatureFlags featureFlags = mock(FeatureFlags.class, RETURNS_SMART_NULLS); diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java index cdc9619d2d..5d21f8e584 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java @@ -25,9 +25,11 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Duration; import java.util.Arrays; +import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.Date; @@ -139,11 +141,17 @@ import org.cloudfoundry.client.v3.tasks.CreateTaskResponse; import org.cloudfoundry.client.v3.tasks.TaskResource; import org.cloudfoundry.doppler.DopplerClient; -import org.cloudfoundry.doppler.Envelope; import org.cloudfoundry.doppler.EventType; import org.cloudfoundry.doppler.LogMessage; import org.cloudfoundry.doppler.RecentLogsRequest; import org.cloudfoundry.doppler.StreamRequest; +import org.cloudfoundry.logcache.v1.Envelope; +import org.cloudfoundry.logcache.v1.EnvelopeBatch; +import org.cloudfoundry.logcache.v1.Log; +import org.cloudfoundry.logcache.v1.LogCacheClient; +import org.cloudfoundry.logcache.v1.LogType; +import org.cloudfoundry.logcache.v1.ReadRequest; +import org.cloudfoundry.logcache.v1.ReadResponse; import org.cloudfoundry.operations.AbstractOperationsTest; import org.cloudfoundry.util.DateUtils; import org.cloudfoundry.util.FluentMap; @@ -163,6 +171,7 @@ final class DefaultApplicationsTest extends AbstractOperationsTest { new DefaultApplications( Mono.just(this.cloudFoundryClient), Mono.just(this.dopplerClient), + Mono.just(this.logCacheClient), this.randomWords, Mono.just(TEST_SPACE_ID)); @@ -1306,8 +1315,9 @@ void listTasks() { .verify(Duration.ofSeconds(5)); } + @SuppressWarnings("deprecation") @Test - void logs() { + void logsDoppler() { requestApplications( this.cloudFoundryClient, "test-application-name", @@ -1318,13 +1328,14 @@ void logs() { this.applications .logs(LogsRequest.builder().name("test-application-name").recent(false).build()) .as(StepVerifier::create) - .expectNext(fill(LogMessage.builder(), "log-message-").build()) + .expectNextMatches(log -> log.getMessage().equals("test-log-message-message")) .expectComplete() .verify(Duration.ofSeconds(5)); } + @SuppressWarnings("deprecation") @Test - void logsNoApp() { + void logsNoAppDoppler() { requestApplicationsEmpty(this.cloudFoundryClient, "test-application-name", TEST_SPACE_ID); this.applications @@ -1339,8 +1350,9 @@ void logsNoApp() { .verify(Duration.ofSeconds(5)); } + @SuppressWarnings("deprecation") @Test - void logsRecent() { + void logsRecentDoppler() { requestApplications( this.cloudFoundryClient, "test-application-name", @@ -1351,13 +1363,38 @@ void logsRecent() { this.applications .logs(LogsRequest.builder().name("test-application-name").recent(true).build()) .as(StepVerifier::create) - .expectNext(fill(LogMessage.builder(), "log-message-").build()) + .expectNextMatches(log -> log.getMessage().equals("test-log-message-message")) .expectComplete() .verify(Duration.ofSeconds(5)); } @Test - void logsRecentNotSet() { + void logsLogCache() { + requestApplications( + this.cloudFoundryClient, + "test-application-name", + TEST_SPACE_ID, + "test-metadata-id"); + requestLogsRecentLogCache(this.logCacheClient, "test-metadata-id"); + + this.applications + .logs(ApplicationLogsRequest.builder().name("test-application-name").build()) + .as(StepVerifier::create) + .expectNextMatches( + log -> + log.getMessage().equals("test-payload") + && log.getLogType() == ApplicationLogType.OUT + && log.getSourceId().equals("test-sourceId") + && log.getInstanceId().equals("test-instanceId") + && log.getSourceType().equals("APP/PROC/WEB") + && log.getTimestamp() == 1L) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @SuppressWarnings("deprecation") + @Test + void logsRecentNotSetDoppler() { requestApplications( this.cloudFoundryClient, "test-application-name", @@ -5317,12 +5354,13 @@ private static void requestListTasksEmpty( .build())); } + @SuppressWarnings("deprecation") private static void requestLogsRecent(DopplerClient dopplerClient, String applicationId) { when(dopplerClient.recentLogs( RecentLogsRequest.builder().applicationId(applicationId).build())) .thenReturn( Flux.just( - Envelope.builder() + org.cloudfoundry.doppler.Envelope.builder() .eventType(EventType.LOG_MESSAGE) .logMessage( fill(LogMessage.builder(), "log-message-").build()) @@ -5330,11 +5368,42 @@ private static void requestLogsRecent(DopplerClient dopplerClient, String applic .build())); } + private static void requestLogsRecentLogCache(LogCacheClient logCacheClient, String sourceId) { + String base64Payload = + Base64.getEncoder().encodeToString("test-payload".getBytes(StandardCharsets.UTF_8)); + when(logCacheClient.read(ReadRequest.builder().sourceId(sourceId).build())) + .thenReturn( + Mono.just( + fill(ReadResponse.builder()) + .envelopes( + fill(EnvelopeBatch.builder()) + .batch( + Arrays.asList( + fill(Envelope.builder()) + .tags( + Collections + .singletonMap( + "source_type", + "APP/PROC/WEB")) + .log( + Log + .builder() + .payload( + base64Payload) + .type( + LogType + .OUT) + .build()) + .build())) + .build()) + .build())); + } + private static void requestLogsStream(DopplerClient dopplerClient, String applicationId) { when(dopplerClient.stream(StreamRequest.builder().applicationId(applicationId).build())) .thenReturn( Flux.just( - Envelope.builder() + org.cloudfoundry.doppler.Envelope.builder() .eventType(EventType.LOG_MESSAGE) .logMessage( fill(LogMessage.builder(), "log-message-").build()) diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 36c30c3578..58b01252f0 100644 --- a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java @@ -48,6 +48,7 @@ import org.cloudfoundry.client.v2.stacks.StackResource; import org.cloudfoundry.client.v2.userprovidedserviceinstances.CreateUserProvidedServiceInstanceRequest; import org.cloudfoundry.doppler.DopplerClient; +import org.cloudfoundry.logcache.v1.LogCacheClient; import org.cloudfoundry.logcache.v1.TestLogCacheEndpoints; import org.cloudfoundry.networking.NetworkingClient; import org.cloudfoundry.operations.DefaultCloudFoundryOperations; @@ -273,6 +274,7 @@ ReactorCloudFoundryClient cloudFoundryClient( DefaultCloudFoundryOperations cloudFoundryOperations( CloudFoundryClient cloudFoundryClient, DopplerClient dopplerClient, + LogCacheClient logCacheClient, NetworkingClient networkingClient, RoutingClient routingClient, UaaClient uaaClient, @@ -281,6 +283,7 @@ DefaultCloudFoundryOperations cloudFoundryOperations( return DefaultCloudFoundryOperations.builder() .cloudFoundryClient(cloudFoundryClient) .dopplerClient(dopplerClient) + .logCacheClient(logCacheClient) .networkingClient(networkingClient) .routingClient(routingClient) .uaaClient(uaaClient) diff --git a/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java b/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java index 36e1bd9456..37c701dbbe 100644 --- a/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java @@ -30,14 +30,6 @@ import org.cloudfoundry.CloudFoundryVersion; import org.cloudfoundry.IfCloudFoundryVersion; import org.cloudfoundry.client.CloudFoundryClient; -import org.cloudfoundry.logcache.v1.Envelope; -import org.cloudfoundry.logcache.v1.EnvelopeBatch; -import org.cloudfoundry.logcache.v1.EnvelopeType; -import org.cloudfoundry.logcache.v1.Log; -import org.cloudfoundry.logcache.v1.LogCacheClient; -import org.cloudfoundry.logcache.v1.LogType; -import org.cloudfoundry.logcache.v1.ReadRequest; -import org.cloudfoundry.logcache.v1.ReadResponse; import org.cloudfoundry.operations.applications.ApplicationDetail; import org.cloudfoundry.operations.applications.ApplicationEnvironments; import org.cloudfoundry.operations.applications.ApplicationEvent; @@ -108,7 +100,6 @@ public final class ApplicationsTest extends AbstractIntegrationTest { @Autowired private String serviceName; - @Autowired private LogCacheClient logCacheClient; @Autowired private CloudFoundryClient cloudFoundryClient; // To create a service in #pushBindService, the Service Broker must be installed first. @@ -508,11 +499,13 @@ public void listTasks() throws IOException { } /** - * Doppler was dropped in PCF 4.x in favor of logcache. This test does not work - * on TAS 4.x. + * Exercise the LogCache client via {@code logs(ApplicationLogsRequest)}. + * LogCache has been a default cf-deployment component since v3.0.0 (July 2018), + * with the {@code /api/v1/read} endpoint available since log-cache-release v2.0.0 + * (October 2018). */ @Test - @IfCloudFoundryVersion(lessThan = CloudFoundryVersion.PCF_4_v2) + @IfCloudFoundryVersion(greaterThanOrEqualTo = CloudFoundryVersion.PCF_2_3) public void logs() throws IOException { String applicationName = this.nameFactory.getApplicationName(); @@ -537,45 +530,6 @@ public void logs() throws IOException { .verify(Duration.ofMinutes(5)); } - /** - * Exercise the LogCache client. Serves as a reference for using the logcache client, - * and will help with the transition to the new - * {@link org.cloudfoundry.operations.applications.Applications#logs(ApplicationLogsRequest)}. - */ - @Test - public void logCacheLogs() throws IOException { - String applicationName = this.nameFactory.getApplicationName(); - - createApplication( - this.cloudFoundryOperations, - new ClassPathResource("test-application.zip").getFile().toPath(), - applicationName, - false) - .then( - this.cloudFoundryOperations - .applications() - .get(GetApplicationRequest.builder().name(applicationName).build())) - .map(ApplicationDetail::getId) - .flatMapMany( - appGuid -> - this.logCacheClient.read( - ReadRequest.builder() - .sourceId(appGuid) - .envelopeType(EnvelopeType.LOG) - .limit(1) - .build())) - .map(ReadResponse::getEnvelopes) - .map(EnvelopeBatch::getBatch) - .flatMap(Flux::fromIterable) - .map(Envelope::getLog) - .map(Log::getType) - .next() - .as(StepVerifier::create) - .expectNext(LogType.OUT) - .expectComplete() - .verify(Duration.ofMinutes(5)); - } - @Test public void pushBindServices() throws IOException { String applicationName = this.nameFactory.getApplicationName(); From d433e8e535864b073ddab14b9f9f6020779e8e20 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Tue, 14 Apr 2026 16:08:39 +0200 Subject: [PATCH 08/10] fix: Restore null-recent default to Doppler streaming PR #1348 changed the null-recent behavior from Doppler streaming to Log Cache. This restores the original default so null behaves like false (streaming), matching the prior logs(LogsRequest) contract. --- .../operations/applications/DefaultApplications.java | 2 +- .../operations/applications/DefaultApplicationsTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java index bb492fffe7..c56ee52554 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/DefaultApplications.java @@ -559,7 +559,7 @@ public Flux logs(LogsRequest request) { @Override public Flux logs(ApplicationLogsRequest request) { - if (request.getRecent() == null || request.getRecent()) { + if (request.getRecent() != null && request.getRecent()) { return Mono.zip(this.cloudFoundryClient, this.spaceId) .flatMap( function( diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java index 5d21f8e584..93f83236aa 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java @@ -1378,7 +1378,7 @@ void logsLogCache() { requestLogsRecentLogCache(this.logCacheClient, "test-metadata-id"); this.applications - .logs(ApplicationLogsRequest.builder().name("test-application-name").build()) + .logs(ApplicationLogsRequest.builder().name("test-application-name").recent(true).build()) .as(StepVerifier::create) .expectNextMatches( log -> From aec614441a469e696e0ef2470c1992ee481dfce3 Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Wed, 15 Apr 2026 15:08:45 +0200 Subject: [PATCH 09/10] Be more verbose about types returned by read() --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 63c7686b42..7fc5cd4a35 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,21 @@ The Cloud Foundry Java Client has two active versions. The `5.x` line is compati > .envelopeTypes(EnvelopeType.LOG) > .build()); > ``` +> +> The return type and envelope objects differ between the two APIs: +> +> | | Doppler (`org.cloudfoundry.doppler`) | Log Cache (`org.cloudfoundry.logcache.v1`) | +> |---|---|---| +> | **Return type** | `Flux` | `Mono` → unpack via `response.getEnvelopes().getBatch()` | +> | **Log access** | `envelope.getLogMessage()` → `LogMessage` | `envelope.getLog()` → `Log` | +> | **Message text** | `logMessage.getMessage()` | `log.getPayloadAsText()` | +> | **Message type** | `MessageType.OUT` / `ERR` | `LogType.OUT` / `ERR` | +> | **Source metadata** | `logMessage.getSourceType()`, `.getSourceInstance()` | `envelope.getTags().get("source_type")`, `envelope.getInstanceId()` | +> +> See the [`org.cloudfoundry.doppler`][doppler-pkg] and [`org.cloudfoundry.logcache.v1`][logcache-pkg] Javadoc for full type details. + +[doppler-pkg]: https://javadoc.io/doc/org.cloudfoundry/cloudfoundry-client/latest/org/cloudfoundry/doppler/package-summary.html +[logcache-pkg]: https://javadoc.io/doc/org.cloudfoundry/cloudfoundry-client/latest/org/cloudfoundry/logcache/v1/package-summary.html > [!NOTE] > **Operations API users:** `Applications.logs(ApplicationLogsRequest)` now uses Log Cache under the hood for recent logs (the default). No migration is needed at the Operations layer. From d47cb4a08cdd0478ab5bda72d5dd8995556a649d Mon Sep 17 00:00:00 2001 From: Joris Baum Date: Fri, 17 Apr 2026 17:52:42 +0200 Subject: [PATCH 10/10] Apply spotless --- .../operations/applications/DefaultApplicationsTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java index 93f83236aa..f137ac2d84 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/DefaultApplicationsTest.java @@ -1378,7 +1378,11 @@ void logsLogCache() { requestLogsRecentLogCache(this.logCacheClient, "test-metadata-id"); this.applications - .logs(ApplicationLogsRequest.builder().name("test-application-name").recent(true).build()) + .logs( + ApplicationLogsRequest.builder() + .name("test-application-name") + .recent(true) + .build()) .as(StepVerifier::create) .expectNextMatches( log ->