From fffcc329d8524ab035301fb38c6ecccd72549f44 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Sat, 3 Oct 2026 21:04:26 +0200 Subject: [PATCH 01/10] feat: Add GitHub workflow for integration tests on multiple Java versions --- .github/workflows/ci-java.yml | 6 +++ .github/workflows/integration-test.yml | 60 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/integration-test.yml diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index b1cf12b2a4..dacd390020 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: '[8, 11, 17, 21]' + diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000000..34915a7fe7 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,60 @@ +name: Integration Tests + +on: + workflow_dispatch: + inputs: + java-versions: + description: 'JSON array of Java versions to test' + required: false + default: '[8, 11, 17, 21]' + workflow_call: + inputs: + java-versions: + description: 'JSON array of Java versions to test' + type: string + required: false + default: '[8, 11, 17, 21]' + schedule: + - cron: '0 3 * * 1-5' + +jobs: + integration-test: + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + java: ${{ fromJSON(inputs.java-versions || '[8, 11, 17, 21]') }} + name: Java ${{ matrix.java }} integration test + steps: + - uses: actions/checkout@v7 + - name: Prepare multi-module build + run: git submodule update --init --recursive + # 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@8f0e3947abd716fc73d4787ce83e74ab73fb86a9 + with: + ref: 8f0e3947abd716fc73d4787ce83e74ab73fb86a9 + - 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 From 4984b4af4420be35575ed30bdf4f8d85c34d2a72 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Tue, 6 Oct 2026 08:06:58 +0200 Subject: [PATCH 02/10] feat: Update the GH workflow --- .github/scripts/resolve-capi-version.sh | 48 +++++++++++++++++++++++++ .github/workflows/ci-java.yml | 2 +- .github/workflows/integration-test.yml | 29 ++++++++++++--- 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100755 .github/scripts/resolve-capi-version.sh 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 dacd390020..3d05984149 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -45,5 +45,5 @@ jobs: needs: build uses: ./.github/workflows/integration-test.yml with: - java-versions: '[8, 11, 17, 21]' + java-versions: '[21]' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 34915a7fe7..116d12ee12 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -7,6 +7,14 @@ on: 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: @@ -14,8 +22,11 @@ on: type: string required: false default: '[8, 11, 17, 21]' - schedule: - - cron: '0 3 * * 1-5' + preview: + description: 'Deploy the latest cf-deployment versions instead of the default ones' + type: boolean + required: false + default: false jobs: integration-test: @@ -24,18 +35,28 @@ jobs: strategy: fail-fast: false matrix: - java: ${{ fromJSON(inputs.java-versions || '[8, 11, 17, 21]') }} - name: Java ${{ matrix.java }} integration test + 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" # 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@8f0e3947abd716fc73d4787ce83e74ab73fb86a9 with: ref: 8f0e3947abd716fc73d4787ce83e74ab73fb86a9 + use-latest-versions: ${{ (inputs.preview || github.event_name == 'schedule') }} + github-token: ${{ github.token }} + # TODO: needs the action commit that includes kind-deployment#514, older ones ignore it + capi-version: ${{ steps.capi.outputs.version }} - name: Set up Java uses: actions/setup-java@v5 with: From ee1fcdc23741535fcbe99bd1536ec72b8c93559d Mon Sep 17 00:00:00 2001 From: Georg Lokowandt Date: Thu, 30 Jul 2026 11:36:19 +0200 Subject: [PATCH 03/10] fix #1370, update to latest CAPI version --- .../java/org/cloudfoundry/client/CloudFoundryClient.java | 2 +- .../java/org/cloudfoundry/client/v3/spaces/Space.java | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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(); } From 0d3983c4ef118ad14be80fc5e0037f5d08950320 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Tue, 6 Oct 2026 14:52:42 +0200 Subject: [PATCH 04/10] fix: Adjust the used GH action and fix the integration test issue --- .github/workflows/integration-test.yml | 18 ++++++++++++++---- .../client/v3/packages/ReactorPackages.java | 14 +++++++++++--- .../v3/packages/ReactorPackagesTest.java | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 116d12ee12..1e23183845 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -47,16 +47,26 @@ jobs: 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). + - name: Write CAPI values override + id: values + if: ${{ steps.capi.outputs.version != '' }} + run: | + 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@8f0e3947abd716fc73d4787ce83e74ab73fb86a9 + uses: cloudfoundry/kind-deployment/.github/actions/setup-cf@8eff1a34c730d89d3c90044695cbcaba048ec68d with: - ref: 8f0e3947abd716fc73d4787ce83e74ab73fb86a9 + ref: 8eff1a34c730d89d3c90044695cbcaba048ec68d use-latest-versions: ${{ (inputs.preview || github.event_name == 'schedule') }} github-token: ${{ github.token }} - # TODO: needs the action commit that includes kind-deployment#514, older ones ignore it - capi-version: ${{ steps.capi.outputs.version }} + additional-values-files: ${{ steps.values.outputs.file }} - name: Set up Java uses: actions/setup-java@v5 with: 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/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()) From 4a98c92c14061ec8b4ad8dc552cd6d084e999a07 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Tue, 6 Oct 2026 15:07:54 +0200 Subject: [PATCH 05/10] fix: Adjust the intergation test --- .github/workflows/integration-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 1e23183845..7733aed5e6 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -48,14 +48,19 @@ jobs: 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. From bed188b22e3487666dd0ec2dd094253b8829f470 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Tue, 6 Oct 2026 17:12:46 +0200 Subject: [PATCH 06/10] fix: Next idea --- .github/workflows/integration-test.yml | 20 +++++ .../applications/ReactorApplicationsV2.java | 18 ++++- .../authorizations/ReactorAuthorizations.java | 23 ++++-- .../ReactorApplicationsV2Test.java | 42 ++++++++++ .../client/CloudFoundryClient.java | 2 +- .../cloudfoundry/client/v3/stacks/Stack.java | 14 ++++ .../client/v3/stacks/StackState.java | 78 +++++++++++++++++++ .../identityproviders/_LdapConfiguration.java | 9 +++ .../identityproviders/_SamlConfiguration.java | 7 ++ .../client/v3/OrganizationsTest.java | 3 +- .../logcache/v1/LogCacheTest.java | 9 +++ .../operations/ApplicationsTest.java | 2 +- 12 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/StackState.java diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 7733aed5e6..ac2b41461e 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -93,4 +93,24 @@ jobs: TEST_ADMIN_CLIENTSECRET: ${{ env.UAA_ADMIN_SECRET }} TEST_SKIPSSLVALIDATION: 'true' TEST_QUOTAS_ROUTES_RESERVEDPORTS: '50' + # kind-deployment has no metric registrar, which the log-cache metric tests need + TEST_SKIP_METRIC_REGISTRAR: 'true' run: ./mvnw -B -Pintegration-test test -Dgpg.skip + # Failures like the UAA browser flows, log-cache or Docker pushes depend on the KinD setup, so + # print what the cluster says about them. + - name: Dump Cloud Foundry diagnostics + if: ${{ failure() }} + run: | + kubectl get pods -A -o wide || true + kubectl get events -A --sort-by=.lastTimestamp | tail -n 100 || true + echo "::group::describe workload pods" + kubectl -n cf-workloads describe pods || true + echo "::endgroup::" + for pod in $(kubectl -n cf-system get pods -o name | grep -E 'uaa|capi|cloud-controller|log-cache|loggregator|rep|diego|gorouter|metric' || true); do + echo "::group::logs $pod" + kubectl -n cf-system logs "$pod" --all-containers --tail=1000 || true + echo "::endgroup::" + done + echo "::group::workload pod logs" + kubectl -n cf-workloads logs --all-containers --tail=200 --prefix -l '!nonexistent' --ignore-errors || true + echo "::endgroup::" 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/uaa/authorizations/ReactorAuthorizations.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/uaa/authorizations/ReactorAuthorizations.java index f929b35dda..5c228617ca 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 @@ -16,7 +16,9 @@ package org.cloudfoundry.reactor.uaa.authorizations; +import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT; import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; +import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_HTML; import io.netty.handler.codec.http.HttpHeaders; import io.netty.util.AsciiString; @@ -99,7 +101,7 @@ public Mono authorizationCodeGrantBrowser( builder -> builder.pathSegment("oauth", "authorize") .queryParam("response_type", ResponseType.CODE), - outbound -> {}, + ReactorAuthorizations::acceptHtml, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -114,7 +116,7 @@ public Mono authorizationCodeGrantHybrid( builder.pathSegment("oauth", "authorize") .queryParam( "response_type", ResponseType.CODE_AND_ID_TOKEN), - outbound -> {}, + ReactorAuthorizations::acceptHtml, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -137,7 +139,7 @@ public Mono implicitGrantBrowser(AuthorizeByImplicitGrantBrowserRequest builder -> builder.pathSegment("oauth", "authorize") .queryParam("response_type", ResponseType.TOKEN), - outbound -> {}, + ReactorAuthorizations::acceptHtml, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -152,7 +154,7 @@ public Mono openIdWithAuthorizationCodeAndIdToken( builder.pathSegment("oauth", "authorize") .queryParam( "response_type", ResponseType.CODE_AND_ID_TOKEN), - outbound -> {}, + ReactorAuthorizations::acceptHtml, ReactorAuthorizations::removeAuthorization) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); @@ -164,7 +166,8 @@ public Mono openIdWithIdToken(AuthorizeByOpenIdWithIdTokenRequest reques request, builder -> builder.pathSegment("oauth", "authorize") - .queryParam("response_type", ResponseType.ID_TOKEN)) + .queryParam("response_type", ResponseType.ID_TOKEN), + ReactorAuthorizations::acceptHtml) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); } @@ -177,11 +180,19 @@ public Mono openIdWithTokenAndIdToken( builder -> builder.pathSegment("oauth", "authorize") .queryParam( - "response_type", ResponseType.TOKEN_AND_ID_TOKEN)) + "response_type", ResponseType.TOKEN_AND_ID_TOKEN), + ReactorAuthorizations::acceptHtml) .map(inbound -> inbound.responseHeaders().get(LOCATION)) .checkpoint(); } + // UAA answers unauthenticated requests that accept JSON with a 403 instead of redirecting to + // the + // login page, so the browser flows have to ask for HTML like a browser does. + private static void acceptHtml(HttpHeaders headers) { + headers.set(ACCEPT, TEXT_HTML); + } + private static Mono removeAuthorization(HttpHeaders request) { return Mono.just(request.remove(AUTHORIZATION)); } 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/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java index 84168e9e92..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.290.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/stacks/Stack.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/Stack.java index 0e90cc053d..879f5172e7 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/Stack.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/Stack.java @@ -39,6 +39,20 @@ public abstract class Stack extends Resource { @Nullable public abstract String getDescription(); + /** + * The state + */ + @JsonProperty("state") + @Nullable + public abstract StackState getState(); + + /** + * The reason for the state + */ + @JsonProperty("state_reason") + @Nullable + public abstract String getStateReason(); + /** * The metadata */ diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/StackState.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/StackState.java new file mode 100644 index 0000000000..d9a795c8b1 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/stacks/StackState.java @@ -0,0 +1,78 @@ +/* + * Copyright 2013-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.stacks; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * The state of the {@link Stack} + */ +public enum StackState { + + /** + * The active state + */ + ACTIVE("ACTIVE"), + + /** + * The deprecated state + */ + DEPRECATED("DEPRECATED"), + + /** + * The disabled state + */ + DISABLED("DISABLED"), + + /** + * The restricted state + */ + RESTRICTED("RESTRICTED"); + + private final String value; + + StackState(String value) { + this.value = value; + } + + @JsonCreator + public static StackState from(String s) { + switch (s.toLowerCase()) { + case "active": + return ACTIVE; + case "deprecated": + return DEPRECATED; + case "disabled": + return DISABLED; + case "restricted": + return RESTRICTED; + default: + throw new IllegalArgumentException(String.format("Unknown stack state: %s", s)); + } + } + + @JsonValue + public String getValue() { + return this.value; + } + + @Override + public String toString() { + return getValue(); + } +} 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..580513459b 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 */ @@ -28,6 +30,13 @@ @Value.Immutable abstract class _LdapConfiguration extends AbstractExternalIdentityProviderConfiguration { + /** + * The PEM encoded CA certificates used to validate the connection + */ + @JsonProperty("caCertificates") + @Nullable + abstract List getCaCertificates(); + /** * Determines whether or not shadow users must be created before login by an administrator. */ 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..c9a7e51a1b 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 @@ -30,6 +30,13 @@ @Value.Immutable abstract class _SamlConfiguration extends AbstractExternalIdentityProviderConfiguration { + /** + * The PEM encoded CA certificates used to validate the connection + */ + @JsonProperty("caCertificates") + @Nullable + abstract List getCaCertificates(); + /** * Determines whether or not shadow users must be created before login by an administrator. */ 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)); } diff --git a/integration-test/src/test/java/org/cloudfoundry/logcache/v1/LogCacheTest.java b/integration-test/src/test/java/org/cloudfoundry/logcache/v1/LogCacheTest.java index 414af210bd..23288fa39d 100644 --- a/integration-test/src/test/java/org/cloudfoundry/logcache/v1/LogCacheTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/logcache/v1/LogCacheTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -83,6 +84,10 @@ public void meta() { } @Test + @DisabledIfEnvironmentVariable( + named = "TEST_SKIP_METRIC_REGISTRAR", + matches = "true", + disabledReason = "Needs the metric registrar, which kind-deployment does not deploy") public void readCounter() { final String name = this.nameFactory.getName("counter-"); final int delta = this.random.nextInt(1000); @@ -102,6 +107,10 @@ public void readCounter() { } @Test + @DisabledIfEnvironmentVariable( + named = "TEST_SKIP_METRIC_REGISTRAR", + matches = "true", + disabledReason = "Needs the metric registrar, which kind-deployment does not deploy") public void readEvent() { final String title = this.nameFactory.getName("event-"); final String body = "This is the body. " + new BigInteger(1024, this.random).toString(32); 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 37c701dbbe..cf7c226c7b 100644 --- a/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java @@ -1957,7 +1957,7 @@ private static Mono createDockerApplication( .push( PushApplicationRequest.builder() .diskQuota(512) - .dockerImage("cloudfoundry/lattice-app") + .dockerImage("cloudfoundry/diego-docker-app") .healthCheckType(ApplicationHealthCheck.PORT) .memory(64) .name(name) From e7fb8179d18c96857b3cb4cc5feb05ec315ea0bb Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Wed, 7 Oct 2026 10:57:51 +0200 Subject: [PATCH 07/10] WIP --- .../IntegrationTestConfiguration.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 58b01252f0..9db3000ad4 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,30 @@ 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(RoutingClient routingClient) { + return routingClient + .routerGroups() + .list(ListRouterGroupsRequest.builder().build()) + .flatMapIterable(ListRouterGroupsResponse::getRouterGroups) + .filter(group -> "default-tcp".equals(group.getName())) + .next() + .flatMap( + group -> + routingClient + .routerGroups() + .update( + UpdateRouterGroupRequest.builder() + .reservablePorts("1025-1122") + .routerGroupId(group.getRouterGroupId()) + .build())) + .then() + .doOnError(t -> this.logger.warn("Unable to reserve TCP router ports", t)) + .onErrorResume(t -> Mono.empty()); + } + @Bean RoutingClient routingClient(ConnectionContext connectionContext, TokenProvider tokenProvider) { return ReactorRoutingClient.builder() From 3ede0c9f95df1eaa72020c6898f3325330a6c9bc Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Wed, 7 Oct 2026 11:22:50 +0200 Subject: [PATCH 08/10] WIP --- .../IntegrationTestConfiguration.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 9db3000ad4..59e07ee298 100644 --- a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java @@ -462,7 +462,21 @@ String planName(NameFactory nameFactory) { // 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(RoutingClient routingClient) { + Mono routerGroupPorts( + ConnectionContext connectionContext, + @Value("${test.admin.password}") String password, + @Value("${test.admin.username}") String username) { + // 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()) From 4d823f67c1abd21ea96b56768ef8b1830fe4da11 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Wed, 7 Oct 2026 11:33:18 +0200 Subject: [PATCH 09/10] WIP --- .../java/org/cloudfoundry/IntegrationTestConfiguration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java index 59e07ee298..f3b6ac3c48 100644 --- a/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java +++ b/integration-test/src/test/java/org/cloudfoundry/IntegrationTestConfiguration.java @@ -466,7 +466,8 @@ Mono routerGroupPorts( ConnectionContext connectionContext, @Value("${test.admin.password}") String password, @Value("${test.admin.username}") String username) { - // Not the shared routingClient: its token provider depends on the cleaner, which depends on this bean + // Not the shared routingClient: its token provider depends on the cleaner, which depends on + // this bean RoutingClient routingClient = ReactorRoutingClient.builder() .connectionContext(connectionContext) From 0239882df2e2a9e8fd02e592cfa905477807d0db Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Wed, 7 Oct 2026 13:16:44 +0200 Subject: [PATCH 10/10] WIP --- .github/workflows/integration-test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index ac2b41461e..10dff177e2 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -72,6 +72,18 @@ jobs: use-latest-versions: ${{ (inputs.preview || github.event_name == 'schedule') }} github-token: ${{ github.token }} additional-values-files: ${{ steps.values.outputs.file }} + # All Cloud Foundry components share one Postgres with the default max_connections (100). Over + # a full test run it runs out ("too many clients already") and UAA, which every Spring test + # context needs, stops working. Raise the limit; it only takes effect after a restart. + - name: Raise Postgres connection limit + run: | + kubectl -n cf-system exec postgresql-0 -- sh -c \ + 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -c "ALTER SYSTEM SET max_connections = 500"' + kubectl -n cf-system delete pod postgresql-0 --wait=true + kubectl -n cf-system wait --for=condition=Ready pod/postgresql-0 --timeout=300s + kubectl -n cf-system exec postgresql-0 -- sh -c \ + 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -tc "SHOW max_connections"' + kubectl -n cf-system rollout status deployment --timeout=300s || true - name: Set up Java uses: actions/setup-java@v5 with: