From ab50897cdafac088216455d1db371fc1bb889fc1 Mon Sep 17 00:00:00 2001 From: agilis allievo Date: Wed, 25 Mar 2026 15:29:55 +0100 Subject: [PATCH 1/2] feat: adds schema support for app features --- .../ApplicationManifestUtilsCommon.java | 21 ++++---- .../_ApplicationManifestCommon.java | 11 ++++ .../ApplicationManifestUtilsV3Test.java | 51 ++++++++++++------- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java index 79b50ab809..d2444d09b7 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java @@ -16,30 +16,25 @@ package org.cloudfoundry.operations.applications; -import static java.util.Collections.emptyMap; +import org.cloudfoundry.util.tuple.Consumer2; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; +import reactor.core.Exceptions; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.TreeMap; +import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.cloudfoundry.util.tuple.Consumer2; -import org.yaml.snakeyaml.DumperOptions; -import org.yaml.snakeyaml.Yaml; -import reactor.core.Exceptions; + +import static java.util.Collections.emptyMap; /** * Common base class for dealing with manifests @@ -71,6 +66,7 @@ static T toApplicationManifestCom asString(application, "domain", variables, builder::domain); asListOfString(application, "domains", variables, builder::domain); asMapOfStringString(application, "env", variables, builder::environmentVariable); + asMap(application, "features", variables, String::valueOf, (k,v) -> builder.feature(k, Boolean.valueOf(v))); asString( application, "health-check-http-endpoint", @@ -430,6 +426,7 @@ static Map toApplicationYaml(_ApplicationManifestCommon applicat ApplicationManifestUtilsCommon::toDockerYaml); putIfPresent(yaml, "domains", applicationManifest.getDomains()); putIfPresent(yaml, "env", applicationManifest.getEnvironmentVariables()); + putIfPresent(yaml, "features", applicationManifest.getFeatures()); putIfPresent( yaml, "health-check-http-endpoint", diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java index 95d53c6930..14a79607b7 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java @@ -46,6 +46,10 @@ interface Builder { Builder environmentVariable(Map.Entry entry); Builder environmentVariables(@Nullable Map entries); Builder putAllEnvironmentVariables(Map entries); + Builder feature(String key, Object value); + Builder feature(Map.Entry entry); + Builder features(@Nullable Map entries); + Builder putAllFeatures(Map entries); Builder healthCheckHttpEndpoint(@Nullable String healthCheckHttpEndpoint); Builder healthCheckType(@Nullable ApplicationHealthCheck healthCheckType); Builder host(String element); @@ -142,6 +146,13 @@ void check() { @Nullable abstract Map getEnvironmentVariables(); + /** + * Manage whether optional capabilities are enabled + */ + @AllowNulls + @Nullable + abstract Map getFeatures(); + /** * The HTTP health check endpoint */ diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java index cf54b8120b..b8e71da756 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java @@ -1,15 +1,44 @@ package org.cloudfoundry.operations.applications; -import static org.junit.jupiter.api.Assertions.*; +import org.cloudfoundry.client.v3.Metadata; +import org.cloudfoundry.client.v3.processes.ReadinessHealthCheckType; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import org.cloudfoundry.client.v3.Metadata; -import org.cloudfoundry.client.v3.processes.ReadinessHealthCheckType; -import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; class ApplicationManifestUtilsV3Test { + @Test + void testWithDockerApp() throws IOException { + ManifestV3 manifest = + ManifestV3.builder() + .application( + ManifestV3Application.builder() + .name("test-app") + .docker(Docker.builder().image("test-image").build()) + .build()) + .build(); + + assertSerializeDeserialize(manifest); + } + + @Test + void testWithFeature() throws IOException { + ManifestV3 manifest = + ManifestV3.builder() + .application( + ManifestV3Application.builder() + .name("test-app") + .feature("file-based-vcap-services", true) + .build()) + .build(); + + assertSerializeDeserialize(manifest); + } + @Test void testGenericApplication() throws IOException { ManifestV3 manifest = @@ -47,20 +76,6 @@ void testGenericApplication() throws IOException { assertSerializeDeserialize(manifest); } - @Test - void testWithDockerApp() throws IOException { - ManifestV3 manifest = - ManifestV3.builder() - .application( - ManifestV3Application.builder() - .name("test-app") - .docker(Docker.builder().image("test-image").build()) - .build()) - .build(); - - assertSerializeDeserialize(manifest); - } - @Test void testWithMetadata() throws IOException { ManifestV3 manifest = From fd95e2cddbca7c26b3d4dca77987073062a1d857 Mon Sep 17 00:00:00 2001 From: agilis allievo Date: Tue, 7 Apr 2026 09:19:59 +0200 Subject: [PATCH 2/2] refactor: Moves logic to manifestV3 --- .../ApplicationManifestUtilsCommon.java | 12 ++- .../ApplicationManifestUtilsV3.java | 29 +++++-- .../_ApplicationManifestCommon.java | 11 --- .../applications/_ManifestV3Application.java | 15 +++- .../ApplicationManifestUtilsV3Test.java | 17 ++++ .../operations/ApplicationsTest.java | 77 ++++++++++++++++--- 6 files changed, 127 insertions(+), 34 deletions(-) diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java index d2444d09b7..3baca9f415 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsCommon.java @@ -26,7 +26,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.*; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.TreeMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -66,7 +72,6 @@ static T toApplicationManifestCom asString(application, "domain", variables, builder::domain); asListOfString(application, "domains", variables, builder::domain); asMapOfStringString(application, "env", variables, builder::environmentVariable); - asMap(application, "features", variables, String::valueOf, (k,v) -> builder.feature(k, Boolean.valueOf(v))); asString( application, "health-check-http-endpoint", @@ -317,7 +322,7 @@ static Map getNamedObject(List array, String name) { value -> value instanceof Map && name.equals( - ((Map) value).get("name"))) + ((Map) value).get("name"))) .findFirst() .orElseGet(() -> getEmptyNamedObject(array, name)); } @@ -426,7 +431,6 @@ static Map toApplicationYaml(_ApplicationManifestCommon applicat ApplicationManifestUtilsCommon::toDockerYaml); putIfPresent(yaml, "domains", applicationManifest.getDomains()); putIfPresent(yaml, "env", applicationManifest.getEnvironmentVariables()); - putIfPresent(yaml, "features", applicationManifest.getFeatures()); putIfPresent( yaml, "health-check-http-endpoint", diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3.java index da335ae704..b085234c0d 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3.java @@ -16,8 +16,12 @@ package org.cloudfoundry.operations.applications; -import static java.util.Collections.emptyMap; -import static java.util.stream.Collectors.toMap; +import org.cloudfoundry.client.v3.Metadata; +import org.cloudfoundry.client.v3.processes.HealthCheckType; +import org.cloudfoundry.client.v3.processes.ReadinessHealthCheckType; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; +import reactor.core.Exceptions; import java.io.IOException; import java.io.OutputStream; @@ -33,12 +37,9 @@ import java.util.TreeMap; import java.util.regex.Pattern; import java.util.stream.Stream; -import org.cloudfoundry.client.v3.Metadata; -import org.cloudfoundry.client.v3.processes.HealthCheckType; -import org.cloudfoundry.client.v3.processes.ReadinessHealthCheckType; -import org.yaml.snakeyaml.DumperOptions; -import org.yaml.snakeyaml.Yaml; -import reactor.core.Exceptions; + +import static java.util.Collections.emptyMap; +import static java.util.stream.Collectors.toMap; /** * Utilities for dealing with {@link ManifestV3}s. Includes the functionality to transform to and from standard CLI YAML files. @@ -156,6 +157,13 @@ private static ManifestV3Application.Builder toApplicationManifest( Path root) { toApplicationManifestCommon(application, variables, builder, root); + asMap( + application, + "features", + variables, + String::valueOf, + (k,v) -> builder.feature(k, Boolean.valueOf(v)) + ); asList( application, "processes", @@ -299,6 +307,11 @@ private static Map toYaml(ManifestV3 manifest) { private static Map toApplicationYaml(ManifestV3Application application) { Map yaml = ApplicationManifestUtilsCommon.toApplicationYaml(application); + putIfPresent( + yaml, + "features", + application.getFeatures() + ); putIfPresent( yaml, "processes", diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java index 14a79607b7..95d53c6930 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ApplicationManifestCommon.java @@ -46,10 +46,6 @@ interface Builder { Builder environmentVariable(Map.Entry entry); Builder environmentVariables(@Nullable Map entries); Builder putAllEnvironmentVariables(Map entries); - Builder feature(String key, Object value); - Builder feature(Map.Entry entry); - Builder features(@Nullable Map entries); - Builder putAllFeatures(Map entries); Builder healthCheckHttpEndpoint(@Nullable String healthCheckHttpEndpoint); Builder healthCheckType(@Nullable ApplicationHealthCheck healthCheckType); Builder host(String element); @@ -146,13 +142,6 @@ void check() { @Nullable abstract Map getEnvironmentVariables(); - /** - * Manage whether optional capabilities are enabled - */ - @AllowNulls - @Nullable - abstract Map getFeatures(); - /** * The HTTP health check endpoint */ diff --git a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ManifestV3Application.java b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ManifestV3Application.java index b235a1eb5d..fe0ac3412e 100644 --- a/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ManifestV3Application.java +++ b/cloudfoundry-operations/src/main/java/org/cloudfoundry/operations/applications/_ManifestV3Application.java @@ -16,9 +16,10 @@ package org.cloudfoundry.operations.applications; +import org.cloudfoundry.AllowNulls; import org.cloudfoundry.Nullable; -import org.immutables.value.Value; import org.cloudfoundry.client.v3.Metadata; +import org.immutables.value.Value; import java.util.List; import java.util.Map; @@ -36,6 +37,13 @@ abstract class _ManifestV3Application extends _ApplicationManifestCommon { @Nullable abstract Boolean getDefaultRoute(); + /** + * Manage whether optional capabilities are enabled + */ + @AllowNulls + @Nullable + abstract Map getFeatures(); + /** * The metadata for this application */ @@ -61,6 +69,9 @@ abstract class _ManifestV3Application extends _ApplicationManifestCommon { abstract List getSidecars(); public abstract static class Builder implements _ApplicationManifestCommon.Builder { - + abstract Builder feature(String key, Object value); + abstract Builder feature(Map.Entry entry); + abstract Builder features(@Nullable Map entries); + abstract Builder putAllFeatures(Map entries); } } diff --git a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java index b8e71da756..fac85f0377 100644 --- a/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java +++ b/cloudfoundry-operations/src/test/java/org/cloudfoundry/operations/applications/ApplicationManifestUtilsV3Test.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -39,6 +40,22 @@ void testWithFeature() throws IOException { assertSerializeDeserialize(manifest); } + @Test + void testWithFeatureAsMap() throws IOException { + Map features = new java.util.HashMap<>(); + features.put("file-based-vcap-services", true); + ManifestV3 manifest = + ManifestV3.builder() + .application( + ManifestV3Application.builder() + .name("test-app") + .features(features) + .build()) + .build(); + + assertSerializeDeserialize(manifest); + } + @Test void testGenericApplication() throws IOException { ManifestV3 manifest = 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..9b836ebf1b 100644 --- a/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java +++ b/integration-test/src/test/java/org/cloudfoundry/operations/ApplicationsTest.java @@ -16,20 +16,13 @@ package org.cloudfoundry.operations; -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; import org.cloudfoundry.AbstractIntegrationTest; import org.cloudfoundry.CleanupCloudFoundryAfterClass; import org.cloudfoundry.CloudFoundryVersion; import org.cloudfoundry.IfCloudFoundryVersion; import org.cloudfoundry.client.CloudFoundryClient; +import org.cloudfoundry.client.v3.applications.ApplicationFeatureResource; +import org.cloudfoundry.client.v3.applications.ListApplicationFeaturesRequest; import org.cloudfoundry.operations.applications.ApplicationDetail; import org.cloudfoundry.operations.applications.ApplicationEnvironments; import org.cloudfoundry.operations.applications.ApplicationEvent; @@ -79,6 +72,7 @@ import org.cloudfoundry.operations.services.GetServiceInstanceRequest; import org.cloudfoundry.operations.services.ServiceInstance; import org.cloudfoundry.util.FluentMap; +import org.cloudfoundry.util.PaginationUtils; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -87,6 +81,16 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + @CleanupCloudFoundryAfterClass public final class ApplicationsTest extends AbstractIntegrationTest { @@ -745,6 +749,61 @@ public void pushManifestV3() throws IOException { .verify(Duration.ofMinutes(5)); } + @Test + @IfCloudFoundryVersion( + greaterThanOrEqualTo = + CloudFoundryVersion.PCF_4_v3) + public void pushManifestV3WithFeature() throws IOException { + String applicationName = this.nameFactory.getApplicationName(); + + final String featureKey = "ssh"; + final boolean featureValue = false; + ManifestV3 manifest = + ManifestV3.builder() + .application( + ManifestV3Application.builder() + .buildpack("staticfile_buildpack") + .disk(512) + .healthCheckType(ApplicationHealthCheck.PORT) + .memory(64) + .name(applicationName) + .feature(featureKey, false) + .path( + new ClassPathResource("test-application.zip") + .getFile() + .toPath()) + .build()) + .build(); + + this.cloudFoundryOperations + .applications() + .pushManifestV3(PushManifestV3Request.builder().manifest(manifest).build()) + .then( + this.cloudFoundryOperations + .applications() + .get(GetApplicationRequest.builder().name(applicationName).build())) + + .map(ApplicationDetail::getId) + .flatMapMany( + applicationId -> + PaginationUtils.requestClientV3Resources( + page -> + this.cloudFoundryClient + .applicationsV3() + .listFeatures( + ListApplicationFeaturesRequest + .builder() + .applicationId(applicationId) + .page(page) + .build()))) + .filter(feature -> featureKey.equals(feature.getName())) + .map(ApplicationFeatureResource::getEnabled) + .as(StepVerifier::create) + .expectNext(featureValue) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + @Test @IfCloudFoundryVersion(greaterThanOrEqualTo = CloudFoundryVersion.PCF_4_v2) public void pushManifestV3WithMetadata() throws IOException {