diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2.java index 0e87bba174..36292061de 100644 --- a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2.java +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import java.util.Map; import org.cloudfoundry.client.v2.applications.ApplicationEnvironmentRequest; import org.cloudfoundry.client.v2.applications.ApplicationEnvironmentResponse; @@ -66,6 +67,7 @@ import org.cloudfoundry.reactor.client.v2.AbstractClientV2Operations; import org.cloudfoundry.reactor.util.MultipartHttpClientRequest; import org.cloudfoundry.util.FileUtils; +import org.springframework.web.util.UriComponentsBuilder; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -356,13 +358,25 @@ private Mono upload( return put( request, UploadApplicationResponse.class, - builder -> builder.pathSegment("apps", request.getApplicationId(), "bits"), + builder -> uploadUri(builder, request), multipartRequest -> upload(request.getApplication(), multipartRequest, request), onTerminate) .checkpoint(); } + // The nginx upload module of CAPI can drop the "resources" form field ("missing :resources"). + // Cloud Controller also takes it as query parameter, but only an empty list fits into a URL. + private static UriComponentsBuilder uploadUri( + UriComponentsBuilder builder, UploadApplicationRequest request) { + builder.pathSegment("apps", request.getApplicationId(), "bits"); + if (request.getResources().isEmpty()) { + builder.queryParam("resources", "{resources}") + .uriVariables(Collections.singletonMap("resources", "[]")); + } + return builder; + } + private void upload( Path application, MultipartHttpClientRequest multipartRequest, diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackages.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackages.java index c28ea0d1a4..01279e990b 100644 --- a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackages.java +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackages.java @@ -16,9 +16,12 @@ package org.cloudfoundry.reactor.client.v3.packages; +import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import java.util.List; import java.util.Map; import org.cloudfoundry.client.v3.packages.CopyPackageRequest; @@ -169,9 +172,12 @@ private void upload(Path bits, List resources, MultipartHttpCli r.addPart(part -> part.setName("bits").setContentType(APPLICATION_ZIP).sendFile(bits)); } - if (resources != null && !resources.isEmpty()) { - r.addPart(part -> part.setName("resources").send(resources)); - } + // CAPI rejects bits uploads without a "resources" part (CF-AppBitsUploadInvalid) + r.addPart( + part -> + part.setName("resources") + .setContentType(APPLICATION_JSON.toString()) + .send(resources == null ? Collections.emptyList() : resources)); r.done(); } diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/uaa/authorizations/ReactorAuthorizations.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/uaa/authorizations/ReactorAuthorizations.java index f929b35dda..589772451d 100644 --- a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/uaa/authorizations/ReactorAuthorizations.java +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/uaa/authorizations/ReactorAuthorizations.java @@ -18,6 +18,8 @@ import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpHeaders; import io.netty.util.AsciiString; import java.util.Map; @@ -99,7 +101,12 @@ public Mono authorizationCodeGrantBrowser( builder -> builder.pathSegment("oauth", "authorize") .queryParam("response_type", ResponseType.CODE), - outbound -> {}, + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -114,7 +121,12 @@ public Mono authorizationCodeGrantHybrid( builder.pathSegment("oauth", "authorize") .queryParam( "response_type", ResponseType.CODE_AND_ID_TOKEN), - outbound -> {}, + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -126,7 +138,8 @@ public Mono getOpenIdProviderConfigurati return get( request, GetOpenIdProviderConfigurationResponse.class, - builder -> builder.pathSegment(".well-known", "openid-configuration")) + builder -> builder.pathSegment(".well-known", "openid-configuration"), + ReactorAuthorizations::removeAuthorization) .checkpoint(); } @@ -137,7 +150,12 @@ public Mono implicitGrantBrowser(AuthorizeByImplicitGrantBrowserRequest builder -> builder.pathSegment("oauth", "authorize") .queryParam("response_type", ResponseType.TOKEN), - outbound -> {}, + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -152,7 +170,12 @@ public Mono openIdWithAuthorizationCodeAndIdToken( builder.pathSegment("oauth", "authorize") .queryParam( "response_type", ResponseType.CODE_AND_ID_TOKEN), - outbound -> {}, + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -164,7 +187,14 @@ public Mono openIdWithIdToken(AuthorizeByOpenIdWithIdTokenRequest reques request, builder -> builder.pathSegment("oauth", "authorize") - .queryParam("response_type", ResponseType.ID_TOKEN)) + .queryParam("response_type", ResponseType.ID_TOKEN), + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }, + ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); } @@ -177,7 +207,13 @@ public Mono openIdWithTokenAndIdToken( builder -> builder.pathSegment("oauth", "authorize") .queryParam( - "response_type", ResponseType.TOKEN_AND_ID_TOKEN)) + "response_type", ResponseType.TOKEN_AND_ID_TOKEN), + outbound -> { + outbound.remove(HttpHeaderNames.ACCEPT); + outbound.add( + HttpHeaderNames.ACCEPT, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED); + }) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); } diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2Test.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2Test.java index c7730693f2..70c8875600 100644 --- a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2Test.java +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v2/applications/ReactorApplicationsV2Test.java @@ -1362,6 +1362,16 @@ void upload() throws IOException { .verify(Duration.ofSeconds(5)); } + @Test + void uploadWithoutResourcesSendsResourcesQueryParameter() throws IOException { + uploadWithoutResources("/apps/test-application-id/bits?resources=%5B%5D", null); + } + + @Test + void uploadAsyncWithoutResourcesSendsResourcesQueryParameter() throws IOException { + uploadWithoutResources("/apps/test-application-id/bits?async=true&resources=%5B%5D", true); + } + @Test void uploadDroplet() throws IOException { mockRequest( @@ -1438,4 +1448,49 @@ void uploadDroplet() throws IOException { .expectComplete() .verify(Duration.ofSeconds(5)); } + + private void uploadWithoutResources(String path, Boolean async) throws IOException { + mockRequest( + InteractionContext.builder() + .request( + TestRequest.builder() + .method(PUT) + .path(path) + .contents( + consumer( + (headers, body) -> + assertThat( + body.readString( + Charset + .defaultCharset())) + .contains( + "name=\"application\"") + .contains( + "name=\"resources\"") + .contains("[]"))) + .build()) + .response( + TestResponse.builder() + .status(CREATED) + .payload( + "fixtures/client/v2/apps/PUT_{id}_bits_response.json") + .build()) + .build()); + + this.applications + .upload( + UploadApplicationRequest.builder() + .application( + new ClassPathResource( + "fixtures/client/v2/apps/test-application.zip") + .getFile() + .toPath()) + .applicationId("test-application-id") + .async(async) + .build()) + .as(StepVerifier::create) + .expectNextCount(1) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } } diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackagesTest.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackagesTest.java index 6ac5f2792c..cb3bf411c1 100644 --- a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackagesTest.java +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/packages/ReactorPackagesTest.java @@ -764,6 +764,20 @@ void upload() throws IOException { + "test-content\r\n" + "--" + boundary + + "\r\n" + + "content-disposition:" + + " form-data;" + + " name=\"resources\"\r\n" + + "content-length:" + + " 2\r\n" + + "content-type:" + + " application/json\r\n" + + "content-transfer-encoding:" + + " binary\r\n" + + "\r\n" + + "[]\r\n" + + "--" + + boundary + "--\r\n"); })) .build()) diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java index 0f85e973e5..84168e9e92 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java @@ -84,7 +84,7 @@ public interface CloudFoundryClient { /** * The currently supported Cloud Controller API version */ - String SUPPORTED_API_VERSION = "2.272.0"; + String SUPPORTED_API_VERSION = "2.290.0"; /** * Main entry point to the Cloud Foundry Application Usage Events Client API diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/spaces/Space.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/spaces/Space.java index 0ea0fc9d09..7d69999a2d 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/spaces/Space.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/spaces/Space.java @@ -45,4 +45,12 @@ public abstract class Space extends Resource { @JsonProperty("relationships") @Nullable public abstract SpaceRelationships getRelationships(); + + /** + * True if the space is suspended and no changes are allowed. + * See: https://v3-apidocs.cloudfoundry.org/index.html#spaces + */ + @JsonProperty("suspended") + @Nullable + public abstract Boolean getSuspended(); } diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_LdapConfiguration.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_LdapConfiguration.java index 75b658b695..42345e795f 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_LdapConfiguration.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_LdapConfiguration.java @@ -21,6 +21,8 @@ import org.cloudfoundry.Nullable; import org.immutables.value.Value; +import java.util.List; + /** * The payload for the ldap identity provider configuration */ @@ -63,6 +65,13 @@ abstract class _LdapConfiguration extends AbstractExternalIdentityProviderConfig @Nullable abstract String getBindUserDistinguishedName(); + /** + * The PEM encoded CA certificates used to validate the connection + */ + @JsonProperty("caCertificates") + @Nullable + abstract List getCaCertificates(); + /** * The group role attribute */ diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_SamlConfiguration.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_SamlConfiguration.java index 4e56d9204e..6a2e895b1e 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_SamlConfiguration.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/uaa/identityproviders/_SamlConfiguration.java @@ -51,6 +51,13 @@ abstract class _SamlConfiguration extends AbstractExternalIdentityProviderConfig @Nullable abstract List getAuthnContext(); + /** + * The PEM encoded CA certificates used to validate the connection + */ + @JsonProperty("caCertificates") + @Nullable + abstract List getCaCertificates(); + /** * Either EXPLICITLY_MAPPED in order to map external groups to OAuth scopes using the group mappings, or AS_SCOPES to use SAML group names as scopes. */ diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 58b01252f0..024c830e54 100644 --- a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java @@ -65,6 +65,9 @@ import org.cloudfoundry.reactor.tokenprovider.PasswordGrantTokenProvider; import org.cloudfoundry.reactor.uaa.ReactorUaaClient; import org.cloudfoundry.routing.RoutingClient; +import org.cloudfoundry.routing.v1.routergroups.ListRouterGroupsRequest; +import org.cloudfoundry.routing.v1.routergroups.ListRouterGroupsResponse; +import org.cloudfoundry.routing.v1.routergroups.UpdateRouterGroupRequest; import org.cloudfoundry.uaa.UaaClient; import org.cloudfoundry.uaa.clients.CreateClientRequest; import org.cloudfoundry.uaa.groups.AddMemberRequest; @@ -251,6 +254,7 @@ String clientSecret(NameFactory nameFactory) { } @Bean + @DependsOn("routerGroupPorts") CloudFoundryCleaner cloudFoundryCleaner( @Qualifier("admin") CloudFoundryClient cloudFoundryClient, NameFactory nameFactory, @@ -455,6 +459,55 @@ String planName(NameFactory nameFactory) { return nameFactory.getPlanName(); } + // Tests allocate TCP ports from NameFactory, so the default router group has to reserve them + // regardless of the order in which the tests run (or of the deployment's default range). + @Bean(initMethod = "block") + Mono routerGroupPorts( + ConnectionContext connectionContext, + @Value("${test.admin.password}") String password, + @Value("${test.admin.username}") String username, + @Value("${skip.tcp.routing.tests:false}") boolean skipTcpRouting) { + if (skipTcpRouting) { + return Mono.empty(); + } + + // Not the shared routingClient: its token provider depends on the cleaner, which depends on + // this bean + RoutingClient routingClient = + ReactorRoutingClient.builder() + .connectionContext(connectionContext) + .tokenProvider( + PasswordGrantTokenProvider.builder() + .password(password) + .username(username) + .build()) + .build(); + + return routingClient + .routerGroups() + .list(ListRouterGroupsRequest.builder().build()) + .flatMapIterable(ListRouterGroupsResponse::getRouterGroups) + .filter(group -> "default-tcp".equals(group.getName())) + .next() + .switchIfEmpty( + Mono.fromRunnable( + () -> + this.logger.warn( + "No default-tcp router group, TCP route tests will" + + " fail"))) + .flatMap( + group -> + routingClient + .routerGroups() + .update( + UpdateRouterGroupRequest.builder() + .reservablePorts( + RandomNameFactory.PORT_RANGE) + .routerGroupId(group.getRouterGroupId()) + .build())) + .then(); + } + @Bean RoutingClient routingClient(ConnectionContext connectionContext, TokenProvider tokenProvider) { return ReactorRoutingClient.builder() diff --git a/integration-test/src/test/java/org/cloudfoundry/RandomNameFactory.java b/integration-test/src/test/java/org/cloudfoundry/RandomNameFactory.java index 5443f15780..6f9b92859c 100644 --- a/integration-test/src/test/java/org/cloudfoundry/RandomNameFactory.java +++ b/integration-test/src/test/java/org/cloudfoundry/RandomNameFactory.java @@ -29,6 +29,11 @@ final class RandomNameFactory implements NameFactory { private static final int PORT_MINIMUM = 1025; + /** + * The router group port range that covers every port handed out by {@link #getPort()} + */ + static final String PORT_RANGE = PORT_MINIMUM + "-" + PORT_MAXIMUM; + private final Random random; private AtomicInteger port = new AtomicInteger(PORT_MINIMUM); diff --git a/pom.xml b/pom.xml index df2a45ed6e..8ea8721f50 100644 --- a/pom.xml +++ b/pom.xml @@ -185,7 +185,7 @@ 3.11.2 - https://projectreactor.io/docs/core/release/api/ + https://javadoc.io/doc/io.projectreactor/reactor-core/latest/ true 8