Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -356,13 +358,25 @@ private Mono<UploadApplicationResponse> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -169,9 +172,12 @@ private void upload(Path bits, List<MatchedResource> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public interface CloudFoundryClient {
/**
* The currently supported Cloud Controller API version
*/
String SUPPORTED_API_VERSION = "2.290.0";
String SUPPORTED_API_VERSION = "2.291.0";

String SUPPORTED_API_VERSION_V3 = "3.225.0";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<String> getCaCertificates();

/**
* The group role attribute
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ abstract class _SamlConfiguration extends AbstractExternalIdentityProviderConfig
@Nullable
abstract List<String> getAuthnContext();

/**
* The PEM encoded CA certificates used to validate the connection
*/
@JsonProperty("caCertificates")
@Nullable
abstract List<String> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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;
Expand Down Expand Up @@ -254,6 +257,7 @@ String clientSecret(NameFactory nameFactory) {
}

@Bean
@DependsOn("routerGroupPorts")
CloudFoundryCleaner cloudFoundryCleaner(
@Qualifier("admin") CloudFoundryClient cloudFoundryClient,
NameFactory nameFactory,
Expand Down Expand Up @@ -454,6 +458,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).
Comment on lines +461 to +462

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still a bit unsure what we're doing with this bean. Are we making sure that ports can be reserved on the default group in the first place, before we do anything else? And this is only so that the reservable range from the CF config is overridden by the config we have in our tests, correct?

This follows the established pattern, which, unfortunately, is hacky and convoluted (e.g. @Bean(initMethod = "block") Mono<...> is bad). Let's try and make it cleaner.

I don't see why the CC Cleaner depends on this though - is it only to avoid doing an initial cleanup if the routing is not possible?

If that's just it, I'm ok paying the price of the initial cleanup just for clarity:

  1. We can re-use the existing routing client
  2. We can use ApplicationContextRunner and make stuff less crazy

e.g.

@Bean
@ConditionalOnExpression("!'true'.equalsIgnoreCase(systemEnvironment['SKIP_TCP_ROUTING_TESTS'])")
ApplicationRunner setReservablePort(RoutingClient routingClient) {
    // Ensure that the default-router-group reservable port range matches whatever our tests use.
    return args -> {
        var defaultGroup = routingClient
                .routerGroups()
                .list(ListRouterGroupsRequest.builder().build())
                .flatMapIterable(ListRouterGroupsResponse::getRouterGroups)
                .filter(group -> "default-tcp".equals(group.getName())).blockFirst();

        if (defaultGroup == null) {
            throw new RuntimeException("No default-tcp router group, TCP route tests will fail. " +
                                   "Set SKIP_TCP_ROUTING_TESTS=true to skip TCP routing tests entirely.");
        }

        routingClient
                .routerGroups()
                .update(
                        UpdateRouterGroupRequest.builder()
                                .reservablePorts(
                                        RandomNameFactory.PORT_RANGE)
                                .routerGroupId(defaultGroup.getRouterGroupId())
                                .build()).block();
    };
}

In the longer run, it'd be much, much better to move the CF cleaner to a similar pattern (but not as part of this PR)

@Bean(initMethod = "block")
Mono<Void> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down