diff --git a/.github/scripts/resolve-capi-version.sh b/.github/scripts/resolve-capi-version.sh new file mode 100755 index 0000000000..bfaaba2818 --- /dev/null +++ b/.github/scripts/resolve-capi-version.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Prints the newest capi-release version whose CC API (v2) version equals SUPPORTED_API_VERSION +# from CloudFoundryClient.java. Needs only git and curl, no GitHub API and so no token. +# +# A capi-release tag pins cloud_controller_ng as a submodule, and that repo records its v2 API +# version in config/version_v2. The release notes aren't reliable for this. The v2 version only +# grows with the tags, so a binary search over the sorted tags is enough. +set -euo pipefail + +source_file="$(dirname "$0")/../../cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java" +target="$(sed -n 's/.*String SUPPORTED_API_VERSION = "\([0-9.]*\)";.*/\1/p' "$source_file")" +[ -n "$target" ] || { echo "SUPPORTED_API_VERSION not found in $source_file" >&2; exit 1; } + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT +# commits and trees only, which is all it takes to read the submodule commit of a tag +git clone --quiet --bare --filter=blob:none https://github.com/cloudfoundry/capi-release.git "$workdir/capi-release" + +mapfile -t tags < <(git -C "$workdir/capi-release" tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V) + +v2_version() { + local sha + sha="$(git -C "$workdir/capi-release" ls-tree "$1" src/cloud_controller_ng | awk '{print $3}')" + curl -fsSL "https://raw.githubusercontent.com/cloudfoundry/cloud_controller_ng/$sha/config/version_v2" | tr -d '[:space:]' +} + +# highest index whose v2 version is <= target +lo=0 +hi=$((${#tags[@]} - 1)) +found=-1 +while [ "$lo" -le "$hi" ]; do + mid=$(((lo + hi) / 2)) + v="$(v2_version "${tags[$mid]}")" + if [ "$(printf '%s\n%s\n' "$v" "$target" | sort -V | tail -1)" = "$target" ]; then + found=$mid + lo=$((mid + 1)) + else + hi=$((mid - 1)) + fi +done + +if [ "$found" -lt 0 ] || [ "$(v2_version "${tags[$found]}")" != "$target" ]; then + echo "No capi-release found for CC API version $target" >&2 + exit 1 +fi + +echo "CC API $target -> capi-release ${tags[$found]}" >&2 +echo "${tags[$found]}" diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index b1cf12b2a4..3d05984149 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -41,3 +41,9 @@ jobs: name: Check style with Spotless run: ./mvnw spotless:check -Pintegration-test + integration-test: + needs: build + uses: ./.github/workflows/integration-test.yml + with: + java-versions: '[21]' + diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000000..7733aed5e6 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,96 @@ +name: Integration Tests + +on: + workflow_dispatch: + inputs: + java-versions: + description: 'JSON array of Java versions to test' + required: false + default: '[8, 11, 17, 21]' + preview: + description: 'Deploy the latest cf-deployment versions instead of the default ones' + type: boolean + required: false + default: false + schedule: + # preview run: latest versions (including CAPI) on the latest JDK only + - cron: '0 3 * * 1-5' + workflow_call: + inputs: + java-versions: + description: 'JSON array of Java versions to test' + type: string + required: false + default: '[8, 11, 17, 21]' + preview: + description: 'Deploy the latest cf-deployment versions instead of the default ones' + type: boolean + required: false + default: false + +jobs: + integration-test: + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + java: ${{ fromJSON(inputs.java-versions || (github.event_name == 'schedule' && '[21]' || '[8, 11, 17, 21]')) }} + name: Java ${{ matrix.java }} integration test${{ (inputs.preview || github.event_name == 'schedule') && ' (preview)' || '' }} + steps: + - uses: actions/checkout@v7 + - name: Prepare multi-module build + run: git submodule update --init --recursive + # Validation runs deploy the capi-release that matches SUPPORTED_API_VERSION. Preview runs + # (scheduled, or started by hand with `preview`) keep the latest versions. + - name: Resolve CAPI version + id: capi + if: ${{ !(inputs.preview || github.event_name == 'schedule') }} + run: echo "version=$(.github/scripts/resolve-capi-version.sh)" >> "$GITHUB_OUTPUT" + # Overrides the capi chart version of kind-deployment (merged last by Helmfile). + # kind-deployment's helmfile passes cloudController.appDomains, which the capi chart only + # accepts from 1.241.0 on (1.240.0 fails its values schema), so older matches are raised to it. + - name: Write CAPI values override + id: values + if: ${{ steps.capi.outputs.version != '' }} + env: + MIN_CAPI_VERSION: 1.241.0 + run: | + version="$(printf '%s\n%s\n' "$MIN_CAPI_VERSION" "${{ steps.capi.outputs.version }}" | sort -V | tail -1)" + cat > "$RUNNER_TEMP/capi-values.yaml" <> "$GITHUB_OUTPUT" + # Every job gets its own Cloud Foundry on KinD, so the matrix can run in parallel. + # The action deploys the kind-deployment ref given in `ref`, so keep both on the same commit. + - name: Set up Cloud Foundry + uses: cloudfoundry/kind-deployment/.github/actions/setup-cf@8eff1a34c730d89d3c90044695cbcaba048ec68d + with: + ref: 8eff1a34c730d89d3c90044695cbcaba048ec68d + use-latest-versions: ${{ (inputs.preview || github.event_name == 'schedule') }} + github-token: ${{ github.token }} + additional-values-files: ${{ steps.values.outputs.file }} + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: liberica + java-version: ${{ matrix.java }} + - name: Cache Maven packages + uses: actions/cache@v6 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Run integration tests + # CC_ADMIN_PASSWORD and UAA_ADMIN_SECRET are exported by setup-cf + env: + TEST_APIHOST: api.cf.127-0-0-1.nip.io + TEST_ADMIN_USERNAME: ccadmin + TEST_ADMIN_PASSWORD: ${{ env.CC_ADMIN_PASSWORD }} + TEST_ADMIN_CLIENTID: admin + TEST_ADMIN_CLIENTSECRET: ${{ env.UAA_ADMIN_SECRET }} + TEST_SKIPSSLVALIDATION: 'true' + TEST_QUOTAS_ROUTES_RESERVEDPORTS: '50' + run: ./mvnw -B -Pintegration-test test -Dgpg.skip 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..7f0bf6d5a8 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,27 @@ 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 CAPI nginx upload module can drop the "resources" form field, which makes Cloud + // Controller reject the upload with "missing :resources". Cloud Controller also accepts it as a + // query parameter, which nginx forwards (upload_pass_args). Only done for the common empty + // list, as a long list of matched resources would not fit 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..bbd558be7e 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,14 @@ 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)); - } + // Always send "resources": CAPI rejects bits uploads (CF-AppBitsUploadInvalid) when the + // field is missing and the front-end proxy does not inject it. + List matched = resources == null ? Collections.emptyList() : resources; + r.addPart( + part -> + part.setName("resources") + .setContentType(APPLICATION_JSON.toString()) + .send(matched)); r.done(); } 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..7cf27fab20 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,48 @@ void upload() throws IOException { .verify(Duration.ofSeconds(5)); } + @Test + void uploadWithoutResourcesSendsResourcesQueryParameter() throws IOException { + mockRequest( + InteractionContext.builder() + .request( + TestRequest.builder() + .method(PUT) + .path("/apps/test-application-id/bits?resources=%5B%5D") + .contents( + consumer( + (headers, body) -> + assertThat( + body.readString( + Charset + .defaultCharset())) + .contains( + "name=\"application\""))) + .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") + .build()) + .as(StepVerifier::create) + .expectNextCount(1) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + @Test void uploadDroplet() throws IOException { mockRequest( 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..826c00641e 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.291.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/integration-test/src/test/java/org/cloudfoundry/client/v3/OrganizationsTest.java b/integration-test/src/test/java/org/cloudfoundry/client/v3/OrganizationsTest.java index a1fbd52f3d..c4b5f9b5a8 100644 --- a/integration-test/src/test/java/org/cloudfoundry/client/v3/OrganizationsTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/client/v3/OrganizationsTest.java @@ -180,8 +180,7 @@ public void getDefaultDomain() { .build())) .map(GetOrganizationDefaultDomainResponse::getName) .as(StepVerifier::create) - .consumeNextWith( - name -> assertThat(name).contains("apps.", ".shepherd.tanzu.broadcom.net")) + .consumeNextWith(name -> assertThat(name).startsWith("apps.")) .expectComplete() .verify(Duration.ofMinutes(5)); }