From a11086051186514634122a76b328c8ca55c439bf Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 9 Jun 2026 20:02:23 +0200 Subject: [PATCH 01/43] Make cache for entitiesWithoutPredictionsPerTenant tenant specific --- .../FioriRecommendationHandler.java | 21 ++++++-- .../RecommendationConfiguration.java | 4 +- .../RecommendationModelChangedHandler.java | 26 ++++++++++ .../FioriRecommendationHandlerTest.java | 48 +++++++++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 4771c9d..dc26ca4 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -31,7 +31,10 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; private final RecommendationResultParser resultParser = new RecommendationResultParser(); - private final Cache entitiesWithoutPredictions = + // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction + // columns. Keys are ":" because if an entity needs a prediction can be + // different across tenants. + private final Cache entitiesWithoutPredictionsPerTenant = Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( @@ -40,14 +43,25 @@ class FioriRecommendationHandler implements EventHandler { this.clientResolver = clientResolver; } + void invalidateTenant(String tenantId) { + String prefix = tenantKey(tenantId) + ":"; + entitiesWithoutPredictionsPerTenant.asMap().keySet().removeIf(k -> k.startsWith(prefix)); + } + + private static String tenantKey(String tenantId) { + return tenantId != null ? tenantId : ""; + } + @After(entity = "*") public void afterRead(CdsReadEventContext context, List dataList) { CdsStructuredType target = context.getTarget(); if (target == null) { return; } + String tenantId = context.getUserInfo().getTenant(); String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { + String cacheKey = tenantKey(tenantId) + ":" + entityName; + if (entitiesWithoutPredictionsPerTenant.getIfPresent(cacheKey) != null) { return; } @@ -82,7 +96,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { var builder = new RecommendationContextBuilder(target, rowType, limit); if (builder.predictionElementNames().isEmpty()) { - entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE); return; } @@ -109,7 +123,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 427d8a4..ee85b46 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -34,7 +34,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { ? (service, tenantId) -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; - configurer.eventHandler(new FioriRecommendationHandler(aiCoreService, resolver)); + FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java new file mode 100644 index 0000000..edeebd7 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -0,0 +1,26 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.ExtensibilityService; +import com.sap.cds.services.mt.ModelChangedEventContext; + +@ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) +class RecommendationModelChangedHandler implements EventHandler { + + private final FioriRecommendationHandler recommendationHandler; + + RecommendationModelChangedHandler(FioriRecommendationHandler recommendationHandler) { + this.recommendationHandler = recommendationHandler; + } + + @On(event = ExtensibilityService.EVENT_MODEL_CHANGED) + public void onModelChanged(ModelChangedEventContext context) { + String tenantId = context.getUserInfo().getTenant(); + recommendationHandler.invalidateTenant(tenantId); + } +} diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index 6ab17fa..6434ce7 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -296,6 +296,50 @@ void rptStyleClient_filledColumns_areExcludedFromRecommendations() { }); } + @Test + void invalidateTenant_removesOnlyThatTenantsEntries() { + // populate cache for two tenants + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + + cut.invalidateTenant("tenant-a"); + + // tenant-a entry gone → db is called again (no early exit) + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + }); + + // tenant-b entry still cached → db is NOT called + reset(db); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + verifyNoInteractions(db); + }); + } + // ── helpers ──────────────────────────────────────────────────────────────── private CdsReadEventContext readContext(String entityName, List> resultRows) { @@ -317,6 +361,10 @@ private void runIn(Runnable test) { runtime.requestContext().run((Consumer) rc -> test.run()); } + private void runInTenant(String tenantId, Runnable test) { + runtime.requestContext().systemUser(tenantId).run((Consumer) rc -> test.run()); + } + private Map draftRow(String col, Object val) { Map row = new HashMap<>(); row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); From 2231d619cdbe87b1a397733256199e5978659090 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:23:41 +0200 Subject: [PATCH 02/43] refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly - Replace resourceGroupForTenant(String) with resourceGroup() on the public AICoreService interface. The implementation reads the tenant from the current RequestContext internally. - Remove isMultiTenancyEnabled() and getRetry() from the public interface; they remain accessible on AbstractAICoreService for internal callers. - Remove the CDS function 'resourceGroupForTenant' from index.cds and its action handler. - Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url property and DeploymentService presence instead of custom flag. - Update RecommendationClientResolver to drop tenantId parameter. - Update samples, tests, and javadoc accordingly. Addresses review comments from PR #49 (Issue 2). --- .../cds/feature/aicore/api/AICoreService.java | 35 +++++------------- .../core/AICoreServiceConfiguration.java | 23 +++++++++--- .../aicore/core/AICoreServiceImpl.java | 4 +- .../aicore/core/AbstractAICoreService.java | 37 +++++++++++++++++++ .../aicore/core/MockAICoreServiceImpl.java | 7 +++- .../aicore/core/handler/ActionHandler.java | 13 ------- .../core/handler/ResourceGroupHandler.java | 2 +- .../resources/cds/com.sap.cds/ai/index.cds | 2 - .../FioriRecommendationHandler.java | 2 +- .../RecommendationConfiguration.java | 10 +++-- .../api/RecommendationClientResolver.java | 2 +- .../api/RptInferenceClient.java | 5 ++- .../FioriRecommendationHandlerTest.java | 2 +- .../handlers/AICoreShowcaseHandler.java | 15 ++++---- 14 files changed, 91 insertions(+), 68 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 6c90866..40374bb 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -5,7 +5,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import io.github.resilience4j.retry.Retry; /** * CAP service contract for SAP AI Core integration. @@ -15,20 +14,16 @@ * provides programmatic helpers to: * *
    - *
  • Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}), - * creating it on-demand when multi-tenancy is enabled. + *
  • Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating + * it on-demand when multi-tenancy is enabled. *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link * #deploymentId(String, ModelDeploymentSpec)}). *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment * ({@link #inferenceClient(String, String)}). - *
  • Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want - * consistent transient-error handling. *
* - *

Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl} - * (when an SAP AI Core service binding is detected) and {@link - * com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local - * development). + *

The implementation is tenant-aware: it reads the current tenant from the {@code + * RequestContext}. Callers do not need to pass tenant identifiers explicitly. */ public interface AICoreService extends CqnService { @@ -45,16 +40,15 @@ public interface AICoreService extends CqnService { String CONFIGURATIONS = "AICore.configurations"; /** - * Returns the AI Core resource group ID associated with the given CDS tenant. + * Returns the AI Core resource group ID associated with the current tenant. * - *

When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is - * returned for every tenant. When enabled, the resource group is looked up by the {@code - * ext.ai.sap.com/CDS_TENANT_ID} label and created on first call if it does not exist. + *

When multi-tenancy is disabled the configured default resource group is returned. When + * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and + * created on first call if it does not exist. * - * @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled - * @return the AI Core resource group ID + * @return the AI Core resource group ID for the current tenant */ - String resourceGroupForTenant(String tenantId); + String resourceGroup(); /** * Returns the deployment ID for the given model spec inside the given resource group. @@ -80,13 +74,4 @@ public interface AICoreService extends CqnService { * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint */ ApiClient inferenceClient(String resourceGroupId, String deploymentId); - - /** Returns whether multi-tenancy is enabled for this service. */ - boolean isMultiTenancyEnabled(); - - /** - * Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so - * downstream inference clients can reuse the same backoff policy. - */ - Retry getRetry(); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 1e31c9b..bfd4c8c 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -14,6 +14,8 @@ import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -50,16 +52,27 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { return envKey != null && !envKey.isBlank(); } + /** + * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} + * property or the presence of a {@link DeploymentService} in the service catalog. This aligns + * with the standard CAP Java convention — no custom property flag is needed. + */ + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; + } + @Override public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); boolean hasBinding = hasAICoreBinding(runtime); - boolean multiTenancyEnabled = - runtime - .getEnvironment() - .getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + boolean multiTenancyEnabled = detectMultiTenancy(runtime); if (hasBinding) { AICoreServiceImpl service = @@ -75,7 +88,7 @@ public void services(CdsRuntimeConfigurer configurer) { logger.info("Registered AICoreService backed by AI Core binding."); } else { MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); configurer.service(mockService); logger.info("Registered MockAICoreService (no AI Core binding found)."); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 91174cd..1fa5ee4 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -25,7 +25,6 @@ import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; @@ -243,8 +242,7 @@ public String resolveResourceGroupFromKeys(Map keys) { if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { return (String) rgMap.get("resourceGroupId"); } - String tenantId = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - return resourceGroupForTenant(tenantId); + return resourceGroup(); } @Override diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 8b8d080..f04a995 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -4,8 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.utils.services.AbstractCqnService; +import io.github.resilience4j.retry.Retry; import java.util.Map; /** @@ -24,6 +26,41 @@ public CdsRuntime getRuntime() { return runtime; } + /** + * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no + * tenant is set (e.g. in single-tenant mode). + */ + protected String currentTenantId() { + return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + } + + /** + * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} + * interface — callers should not need to be aware of multi-tenancy. + */ + public abstract boolean isMultiTenancyEnabled(); + + /** + * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the + * public {@link AICoreService} interface but accessible to internal callers (e.g. the + * recommendations module) that need consistent backoff behaviour. + */ + public abstract Retry getRetry(); + + /** + * Returns the resource group for the given tenant ID. This is an internal method used by setup + * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context. + * + * @param tenantId the CDS tenant identifier + * @return the AI Core resource group ID + */ + public abstract String resourceGroupForTenant(String tenantId); + + @Override + public String resourceGroup() { + return resourceGroupForTenant(currentTenantId()); + } + /** Returns the configured default resource group identifier. */ public abstract String getDefaultResourceGroup(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index c54b4b9..84a3602 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -26,6 +26,10 @@ public class MockAICoreServiceImpl extends AbstractAICoreService { private final boolean multiTenancyEnabled; public MockAICoreServiceImpl(String name, CdsRuntime runtime) { + this(name, runtime, false); + } + + public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { super(name, runtime); logger.info("MockAICoreService initialized - all operations use in-memory storage."); this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); @@ -34,8 +38,7 @@ public MockAICoreServiceImpl(String name, CdsRuntime runtime) { env.getProperty("cds.requires.AICore.resourceGroup", String.class, "default"); this.resourceGroupPrefix = env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); - this.multiTenancyEnabled = - env.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + this.multiTenancyEnabled = multiTenancyEnabled; } @Override diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index b454a2f..810068b 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -12,7 +12,6 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.request.RequestContext; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,18 +25,6 @@ public ActionHandler(AICoreServiceImpl service) { super(service); } - @On(event = "resourceGroupForTenant") - public void onResourceGroupForTenant(EventContext context) { - Map params = asMap(context.get("data")); - String tenant = (String) params.get("tenant"); - if (tenant == null) { - tenant = RequestContext.getCurrent(service.getRuntime()).getUserInfo().getTenant(); - } - String result = service.resourceGroupForTenant(tenant); - context.put("result", result); - context.setCompleted(); - } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 3295ebe..8afd43d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -159,7 +159,7 @@ private String resolveResourceGroupId(Map keys) { if (keys.containsKey(ResourceGroups.TENANT_ID)) { return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.getDefaultResourceGroup(); + return service.resourceGroup(); } private static List toSdkLabels(List> labels) { diff --git a/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds index f60e272..d823f49 100644 --- a/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds +++ b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds @@ -90,8 +90,6 @@ service AICore { on 1 = 1; }; - function resourceGroupForTenant(tenant: String) returns String; - type BckndResourceGroupLabels : many BckndResourceGroupLabel; type BckndResourceGroupLabel { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index ff78d57..c28af81 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -125,7 +125,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index b32a120..879f0fe 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.recommendation; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; @@ -35,7 +36,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { RecommendationClientResolver resolver = aiCoreService instanceof MockAICoreServiceImpl - ? (service, tenantId) -> new MockRecommendationClient() + ? service -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); @@ -43,10 +44,11 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } - private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { - String resourceGroup = service.resourceGroupForTenant(tenantId); + private static RecommendationClient resolveRptClient(AICoreService service) { + String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); return new RptInferenceClient( - service.inferenceClient(resourceGroup, deploymentId), service.getRetry()); + service.inferenceClient(resourceGroup, deploymentId), + ((AbstractAICoreService) service).getRetry()); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 38db2ad..ecc6837 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -8,5 +8,5 @@ @FunctionalInterface public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService, String tenantId); + RecommendationClient resolve(AICoreService aiCoreService); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 0616340..2865dd3 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -31,10 +31,11 @@ * *

{@code
  * AICoreService service = ...;
- * String rg = service.resourceGroupForTenant(tenantId);
+ * String rg = service.resourceGroup();
  * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
  * RptInferenceClient client =
- *     new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry());
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId),
+ *         ((AbstractAICoreService) service).getRetry());
  * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
  * }
*/ diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index c007160..c7f1a22 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -67,7 +67,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service, tenantId) -> predictionClient); + cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); } // ── tests ────────────────────────────────────────────────────────────────── diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 8923382..8023ca2 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -3,6 +3,7 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -14,7 +15,6 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import java.util.ArrayList; import java.util.HashMap; @@ -43,16 +43,14 @@ public void onReadConfigurations(CdsReadEventContext context) { @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String tenantId = (String) context.get("tenantId"); - String rgId = getAICoreService().resourceGroupForTenant(tenantId); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rgId = getAICoreService().resourceGroupForTenant(tenant); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @@ -139,11 +137,12 @@ public void onPredictCategory(EventContext context) { } AICoreService service = getAICoreService(); - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rg = service.resourceGroupForTenant(tenant); + String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry()); + new RptInferenceClient( + service.inferenceClient(rg, deploymentId), + ((AbstractAICoreService) service).getRetry()); List predictions = client.predict(rows, List.of("category"), "ID"); List> results = new ArrayList<>(); From de4f63701f9c4c28a3021d05be75cce617b97456 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:27:53 +0200 Subject: [PATCH 03/43] feat(ai-core): restrict AICore entity APIs to current tenant - Add tenant ownership verification on ResourceGroupHandler for READ (by-key), UPDATE, and DELETE operations. Returns 404 if the resource group belongs to a different tenant. - Scope list queries (READ without key) to the current tenant's resource groups via the tenant label filter in multi-tenancy mode. - Add ensureResourceGroupAccessible() guard to DeploymentHandler and ConfigurationHandler, validating the addressed resource group belongs to the current tenant before forwarding to AI Core. - Provider/system users are exempt from tenant restrictions and can access all resource groups (useful for ops/debug scenarios). - Add isProviderUser() and currentTenantId() as public helpers on AbstractAICoreService for use by handler classes. Addresses review comments from PR #49 (Issue 3a). --- .../aicore/core/AbstractAICoreService.java | 12 +++- .../core/handler/AbstractCrudHandler.java | 28 +++++++++ .../core/handler/ConfigurationHandler.java | 2 + .../core/handler/DeploymentHandler.java | 4 ++ .../core/handler/ResourceGroupHandler.java | 59 +++++++++++++++++-- 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index f04a995..0e7ec79 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -5,6 +5,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.utils.services.AbstractCqnService; import io.github.resilience4j.retry.Retry; @@ -30,10 +31,19 @@ public CdsRuntime getRuntime() { * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no * tenant is set (e.g. in single-tenant mode). */ - protected String currentTenantId() { + public String currentTenantId() { return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); } + /** + * Returns whether the current request is running as a system/provider user. Provider users are + * allowed to see all tenants' resources. + */ + public boolean isProviderUser() { + UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + /** * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} * interface — callers should not need to be aware of multi-tenancy. diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 402ab9b..0549daa 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -3,7 +3,10 @@ */ package com.sap.cds.feature.aicore.core.handler; +import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; import java.util.HashMap; import java.util.List; @@ -22,6 +25,31 @@ protected String resolveResourceGroup(Map keys) { return service.resolveResourceGroupFromKeys(keys); } + /** + * Validates that the given resource group is accessible by the current tenant. Provider/system + * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws + * 404 if the resource group does not belong to the current tenant. + */ + protected void ensureResourceGroupAccessible(String resourceGroupId) { + if (service.isProviderUser() || !service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId); + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); + } + protected static Map merge(Map keys, Map values) { Map merged = new HashMap<>(values); keys.forEach( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index 7ad70de..bafdbff 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -52,6 +52,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); logger.debug( "Reading configurations for resourceGroup={}, keys={}, values={}", resourceGroupId, @@ -79,6 +80,7 @@ public void onCreate(CdsCreateEventContext context, List entries for (Configurations entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); AiConfigurationBaseData request = AiConfigurationBaseData.create() diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index d5907b7..0b3df10 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -56,6 +56,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); String id = (String) keys.get(Deployments.ID); if (id != null) { @@ -75,6 +76,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { for (Deployments entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = @@ -111,6 +113,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); + ensureResourceGroupAccessible(resourceGroupId); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); @@ -135,6 +138,7 @@ public void onDelete(CdsDeleteEventContext context) { String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); + ensureResourceGroupAccessible(resourceGroupId); deploymentApi.delete(resourceGroupId, deploymentId); logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 8afd43d..b0e7094 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -19,6 +19,8 @@ import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; @@ -60,13 +62,10 @@ public void onRead(CdsReadEventContext context) { if (resourceGroupId != null) { BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + ensureOwnedByCurrentTenant(rg); context.setResult(List.of(toMap(rg))); } else { - List labelSelector = null; - if (values.containsKey(ResourceGroups.TENANT_ID)) { - String tenantId = (String) values.get(ResourceGroups.TENANT_ID); - labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); - } + List labelSelector = buildTenantLabelSelector(values); BckndResourceGroupList result = resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); @@ -124,6 +123,7 @@ public void onUpdate(CdsUpdateEventContext context) { Map keys = analyzer.analyze(update).targetKeys(); String resourceGroupId = resolveResourceGroupId(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -147,6 +147,8 @@ public void onDelete(CdsDeleteEventContext context) { Map keys = analyzer.analyze(delete).targetKeys(); String resourceGroupId = resolveResourceGroupId(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + resourceGroupApi.delete(resourceGroupId); logger.debug("Deleted resource group {}", resourceGroupId); context.setResult(List.of()); @@ -162,6 +164,53 @@ private String resolveResourceGroupId(Map keys) { return service.resourceGroup(); } + /** + * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider + * users are restricted to their own tenant's resource groups. + */ + private List buildTenantLabelSelector(Map values) { + // If a specific tenantId is requested in the query, use that + if (values.containsKey(ResourceGroups.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroups.TENANT_ID); + return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + } + // In MT mode, restrict non-provider users to their own tenant + if (service.isMultiTenancyEnabled() && !service.isProviderUser()) { + String currentTenant = service.currentTenantId(); + if (currentTenant != null) { + return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant); + } + } + return null; + } + + /** + * Verifies that the given resource group is owned by the current tenant. Provider/system users + * are allowed to access any resource group. Throws 404 if the resource group belongs to a + * different tenant. + */ + private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { + if (service.isProviderUser()) { + return; + } + if (!service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource group not found"); + } + private static List toSdkLabels(List> labels) { return labels.stream() .map( From c30080ba4dff346332e04461b86e34ba0737ef59 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:29:55 +0200 Subject: [PATCH 04/43] chore(ai-core): rename config namespace to cds.ai.core - Rename all configuration properties from cds.requires.AICore.* to cds.ai.core.* to align with CAP Java property naming conventions. - Rename cds.requires.recommendations.contextRowLimit to cds.ai.recommendations.contextRowLimit. - Drop the cds.requires.AICore.multiTenancy flag entirely; multi- tenancy is now auto-detected from standard CAP Java properties. - Update README with new configuration namespace and examples. Addresses review comments from PR #49 (Issue 3b). --- cds-feature-ai-core/README.md | 20 ++++++++++--------- .../aicore/core/AICoreServiceImpl.java | 8 ++++---- .../aicore/core/MockAICoreServiceImpl.java | 4 ++-- .../AICoreServiceImplDeploymentIdTest.java | 8 ++++---- .../FioriRecommendationHandler.java | 2 +- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md index c447859..1b1125f 100644 --- a/cds-feature-ai-core/README.md +++ b/cds-feature-ai-core/README.md @@ -36,19 +36,21 @@ Without a binding the plugin registers a mock implementation. ## Configuration -All configuration is under the `cds.requires.AICore` namespace in `application.yaml`: +All configuration is under the `cds.ai.core` namespace in `application.yaml`: ```yaml cds: - requires: - AICore: + ai: + core: resourceGroup: default # Resource group for single-tenant mode resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups - multiTenancy: false # Enable per-tenant resource groups maxRetries: 10 # Max retry attempts for transient AI Core errors initialDelayMs: 300 # Initial backoff delay (ms) ``` +Multi-tenancy is auto-detected from CAP Java's standard `cds.multiTenancy.sidecar.url` setting +and the presence of a `DeploymentService`. No additional configuration flag is required. + ## CDS Service: `AICore` The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs as CDS entities: @@ -61,11 +63,11 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs | `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking | | `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables | -### Functions & Actions +### Programmatic API ```java -// Get the resource group ID for a CDS tenant -String rgId = aiCoreService.resourceGroupForTenant(tenantId); +// Get the resource group for the current tenant +String rgId = aiCoreService.resourceGroup(); // Get (or auto-create) a deployment ID for a model spec in the given resource group String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); @@ -73,7 +75,7 @@ String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); ## Multi-Tenancy -When `cds.requires.AICore.multiTenancy=true`: +When multi-tenancy is active (detected via `cds.multiTenancy.sidecar.url`): 1. **Subscribe** - Creates resource group `{prefix}{tenantId}` with label `ext.ai.sap.com/CDS_TENANT_ID` 2. **Unsubscribe** - Deletes the tenant's resource group @@ -92,7 +94,7 @@ AICoreService aiCore = runtime.getServiceCatalog() Result rgs = aiCore.run(Select.from("AICore.resourceGroups")); // Resolve a deployment and obtain a configured ApiClient for it -String resourceGroupId = aiCore.resourceGroupForTenant(tenantId); +String resourceGroupId = aiCore.resourceGroup(); String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1()); ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId); ``` diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 1fa5ee4..f3d4129 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -99,14 +99,14 @@ public AICoreServiceImpl( this.multiTenancyEnabled = multiTenancyEnabled; CdsEnvironment env = runtime.getEnvironment(); this.maxRetries = - env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); this.initialDelayMs = - env.getProperty("cds.requires.AICore.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); this.defaultResourceGroup = - env.getProperty("cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); this.resourceGroupPrefix = env.getProperty( - "cds.requires.AICore.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); this.retry = buildRetry(maxRetries, initialDelayMs); this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index 84a3602..aaee071 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -35,9 +35,9 @@ public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenan this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); CdsEnvironment env = runtime.getEnvironment(); this.defaultResourceGroup = - env.getProperty("cds.requires.AICore.resourceGroup", String.class, "default"); + env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); this.resourceGroupPrefix = - env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); + env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); this.multiTenancyEnabled = multiTenancyEnabled; } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 1d7b670..76c7774 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -68,13 +68,13 @@ void setUp() { CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); // Use small retry counts so failures don't slow tests. - when(env.getProperty(eq("cds.requires.AICore.maxRetries"), eq(Integer.class), any())) + when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) .thenReturn(1); - when(env.getProperty(eq("cds.requires.AICore.initialDelayMs"), eq(Long.class), any())) + when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) .thenReturn(1L); - when(env.getProperty(eq("cds.requires.AICore.resourceGroup"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); - when(env.getProperty(eq("cds.requires.AICore.resourceGroupPrefix"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) .thenReturn("cds-"); service = diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index c28af81..e7ea5d5 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -91,7 +91,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { .getCdsRuntime() .getEnvironment() .getProperty( - "cds.requires.recommendations.contextRowLimit", + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); From 1eb8c33a87012e4460a793e1b0709dc3e472586d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 14:52:38 +0200 Subject: [PATCH 05/43] fix(ai-core): handle null tenant in resourceGroupForTenant When resourceGroupForTenant is called with a null tenantId (which happens when currentTenantId() returns null in single-tenant or non-tenant-scoped RequestContexts), fall back to the default resource group instead of passing null to the Caffeine cache (which throws NPE). This fixes integration test failures in the CI pipeline where the ApplicationServiceDelegation and Recommendation tests run without an explicit tenant in the RequestContext. --- .../com/sap/cds/feature/aicore/core/AICoreServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index f3d4129..52eeb4f 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -125,8 +125,8 @@ private static Cache newCache() { @Override public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled) { - logger.debug("Multi-tenancy disabled, using resource group {}", defaultResourceGroup); + if (!multiTenancyEnabled || tenantId == null) { + logger.debug("Using default resource group {}", defaultResourceGroup); return defaultResourceGroup; } return getOrCreateResourceGroupForTenant(tenantId); From e730fa80770e8e3004fd5ee49959901830822ab0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 15:05:19 +0200 Subject: [PATCH 06/43] fix(ci): cleanup all run attempts and cds-itest resource groups The cleanup step previously only deleted resource groups matching the exact current run_id AND run_attempt. When a run failed and was re-run, the previous attempt's resource groups were never cleaned up, eventually hitting the AI Core resource group limit (50). Changes: - Match prefix 'itest-{run_id}-' (all attempts) instead of the exact 'itest-{run_id}-{run_attempt}' string. - Same for 'sonar-{run_id}-' prefix. - Also delete 'cds-itest-' prefixed resource groups which are created by the multi-tenancy integration tests via resourceGroupForTenant() and were never cleaned up by the pipeline. --- .github/workflows/pipeline.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2e7b238..24eb6d7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -114,7 +114,9 @@ jobs: const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}']; + // Match ALL attempts for this run (not just current attempt) plus cds-itest- RGs + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); @@ -122,7 +124,7 @@ jobs: const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); console.log('Delete', rg.resourceGroupId, '->', res.status); } - console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}'); + console.log('Cleaned up', toDelete.length, 'resource groups for run ' + runId + ' (all attempts)'); })().catch(e => { console.error(e.message); process.exit(1); }); " From 6ebc3fd65b86848b6077330f2cc72272e6708c14 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:34 +0200 Subject: [PATCH 07/43] fix(itest): align config namespace with cds.ai.core rename The source code (commit c30080b) renamed properties from cds.requires.AICore.* to cds.ai.core.*, but the integration test application.yaml files were not updated. This meant the CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored and tests always ran against the literal default resource group. - spring/application.yaml: cds.requires.AICore -> cds.ai.core - mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy (now auto-detected from cds.multi-tenancy.sidecar.url) --- .../mtx-local/srv/src/main/resources/application.yaml | 3 --- integration-tests/spring/src/main/resources/application.yaml | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml index df8c3e4..2bf2b72 100644 --- a/integration-tests/mtx-local/srv/src/main/resources/application.yaml +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -16,9 +16,6 @@ cds: spring: config.activate.on-profile: local-with-tenants cds: - requires: - AICore: - multiTenancy: true security: mock: tenants: diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index cefb50a..73e07c4 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,8 +7,8 @@ spring: mode: always cds: - requires: - AICore: + ai: + core: resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} maxRetries: 15 security: From 8c0197e0ae56631a6dc7bf5fee66e2f9b3ae89ad Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:43 +0200 Subject: [PATCH 08/43] test(ai-core): add unit tests for tenant scoping and mock service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover new code paths introduced by the tenant-scoping branch: - TenantScopingTest (7 tests): exercises every branch of AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass, single-tenancy bypass, null tenant, matching/non-matching labels, 404. - MockAICoreServiceImplTest (9 tests): both constructors, MT enabled/disabled, resourceGroupForTenant, cache isolation, clearTenantCache, getRetry, config property reads. - AICoreServiceImplDeploymentIdTest (+2 tests): resourceGroupForTenant(null) returns default even with MT enabled; single-tenancy always returns default. --- .../AICoreServiceImplDeploymentIdTest.java | 34 ++++ .../core/MockAICoreServiceImplTest.java | 106 ++++++++++++ .../core/handler/TenantScopingTest.java | 154 ++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 76c7774..f5e3396 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -192,6 +192,40 @@ void secondCallUsesCachedResult_singleQueryToApi() { verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); } + @Test + void resourceGroupForTenant_nullTenantId_returnsDefault() { + // Even with MT enabled, a null tenantId should fall back to the default resource group. + CdsRuntime rtMt = mock(CdsRuntime.class); + CdsEnvironment envMt = mock(CdsEnvironment.class); + when(rtMt.getEnvironment()).thenReturn(envMt); + when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); + when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); + when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("my-default"); + when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + AICoreServiceImpl mtService = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + rtMt, + true, // multi-tenancy enabled + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + + String result = mtService.resourceGroupForTenant(null); + assertThat(result).isEqualTo("my-default"); + } + + @Test + void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { + // Single-tenancy always returns default regardless of the tenantId passed. + String result = service.resourceGroupForTenant("any-tenant"); + assertThat(result).isEqualTo("default"); + } + @Test void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { // Empty deployment list → falls through to create path. diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java new file mode 100644 index 0000000..a8dd214 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -0,0 +1,106 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.Test; + +class MockAICoreServiceImplTest { + + private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("test-rg"); + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("prefix-"); + return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + } + + @Test + void defaultConstructor_setsMultiTenancyFalse() { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + assertThat(service.isMultiTenancyEnabled()).isFalse(); + } + + @Test + void mtConstructor_setsMultiTenancyTrue() { + MockAICoreServiceImpl service = createService(true); + assertThat(service.isMultiTenancyEnabled()).isTrue(); + } + + @Test + void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg"); + assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg"); + } + + @Test + void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() { + MockAICoreServiceImpl service = createService(true); + String rg = service.resourceGroupForTenant("my-tenant"); + assertThat(rg).isEqualTo("prefix-my-tenant"); + } + + @Test + void resourceGroupForTenant_mtEnabled_cachesResult() { + MockAICoreServiceImpl service = createService(true); + String first = service.resourceGroupForTenant("t1"); + String second = service.resourceGroupForTenant("t1"); + assertThat(first).isSameAs(second); + assertThat(service.getTenantResourceGroupCache()).containsKey("t1"); + } + + @Test + void clearTenantCache_removesCorrectEntries() { + MockAICoreServiceImpl service = createService(true); + service.resourceGroupForTenant("t1"); + service.resourceGroupForTenant("t2"); + var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec( + "scenario", "exec", "cfg1", java.util.List.of(), d -> true); + service.deploymentId("prefix-t1", spec); + + service.clearTenantCache("t1"); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1"); + assertThat(service.getTenantResourceGroupCache()).containsKey("t2"); + assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1"); + } + + @Test + void getRetry_returnsNonNull() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getRetry()).isNotNull(); + } + + @Test + void getDefaultResourceGroup_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + } + + @Test + void getResourceGroupPrefix_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java new file mode 100644 index 0000000..d1171ec --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -0,0 +1,154 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ServiceException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}. + * Tests the {@code ensureResourceGroupAccessible} logic which is used by + * ConfigurationHandler and DeploymentHandler. + */ +@ExtendWith(MockitoExtension.class) +class TenantScopingTest { + + @Mock private AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + + /** Concrete subclass to expose the protected method for testing. */ + private static class TestableHandler extends AbstractCrudHandler { + TestableHandler(AICoreServiceImpl service) { + super(service); + } + + void callEnsureResourceGroupAccessible(String resourceGroupId) { + ensureResourceGroupAccessible(resourceGroupId); + } + } + + private TestableHandler handler; + + @BeforeEach + void setUp() { + handler = new TestableHandler(service); + } + + // ── ensureResourceGroupAccessible ────────────────────────────────────────── + + @Test + void providerUser_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(true); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void singleTenancy_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_nullTenant_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn(null); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_matchingTenantLabel_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a")) + .doesNotThrowAnyException(); + } + + @Test + void multiTenancy_nonMatchingTenantLabel_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_noLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_emptyLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(List.of()); + when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } +} From 751227395232ac2c1d068501e6c124aca95764a2 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 10 Jun 2026 20:38:49 +0200 Subject: [PATCH 09/43] chore(recommendations): add TODO for model-changed integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the missing E2E coverage for RecommendationModelChangedHandler. The proper test requires an extensibility-enabled sidecar with extension JSON that adds prediction columns — not yet set up in mtx-local. The cache-invalidation logic itself is covered by the existing unit test FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries. --- .../RecommendationModelChangedHandler.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java index edeebd7..5ea3944 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -9,6 +9,16 @@ import com.sap.cds.services.mt.ExtensibilityService; import com.sap.cds.services.mt.ModelChangedEventContext; +// TODO Integration test needed for the cache-invalidation behaviour. +// The proper E2E pattern (cf. cds-services ExtendViaSidecarTest) requires: +// - extensibility-enabled mtx-local sidecar (/-/cds/extensibility/set) +// - an extension JSON adding a prediction column to a draft-enabled entity +// - per-tenant SQLite schema that survives the model mutation +// - assert that an OData read returns SAP_Recommendations only AFTER the +// extension is applied AND EVENT_MODEL_CHANGED has been emitted. +// The unit test in FioriRecommendationHandlerTest.invalidateTenant_* +// already covers the cache-invalidation logic in isolation; what is missing +// is the wiring + observable-effect assertion through MockMvc. @ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) class RecommendationModelChangedHandler implements EventHandler { From b105dde6833d27a9e9cbf3cb8198807fa54a5170 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 10:27:44 +0200 Subject: [PATCH 10/43] update cleanup --- .github/actions/integration-tests/action.yml | 42 +++++++++++++ .github/actions/scan-with-sonar/action.yml | 42 +++++++++++++ .github/workflows/pipeline.yml | 63 -------------------- 3 files changed, 84 insertions(+), 63 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 49a4060..c023e11 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -34,3 +34,45 @@ runs: run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml working-directory: integration-tests shell: bash + + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + run: | + cds bind --exec -- node -e " + const https = require('https'); + const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); + if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } + const tokenUrl = key.url + '/oauth/token'; + const apiUrl = key.serviceurls.AI_API_URL; + function fetch(url, opts) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { + let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); + }); + req.on('error',reject); + if(opts.body) req.write(opts.body); + req.end(); + }); + } + (async () => { + const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); + const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); + const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); + const token = JSON.parse(tokenRes.body).access_token; + const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); + const groups = JSON.parse(rgRes.body).resources || []; + const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); + for (const rg of toDelete) { + const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); + console.log('Delete', rg.resourceGroupId, '->', res.status); + } + console.log('Cleaned up', toDelete.length, 'resource groups'); + })().catch(e => { console.error(e.message); process.exit(0); }); + " || true diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index c01bf24..86c3ded 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -48,6 +48,48 @@ runs: working-directory: integration-tests shell: bash + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + run: | + cds bind --exec -- node -e " + const https = require('https'); + const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); + if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } + const tokenUrl = key.url + '/oauth/token'; + const apiUrl = key.serviceurls.AI_API_URL; + function fetch(url, opts) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { + let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); + }); + req.on('error',reject); + if(opts.body) req.write(opts.body); + req.end(); + }); + } + (async () => { + const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); + const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); + const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); + const token = JSON.parse(tokenRes.body).access_token; + const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; + const runId = '${{ github.run_id }}'; + const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); + const groups = JSON.parse(rgRes.body).resources || []; + const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); + for (const rg of toDelete) { + const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); + console.log('Delete', rg.resourceGroupId, '->', res.status); + } + console.log('Cleaned up', toDelete.length, 'resource groups'); + })().catch(e => { console.error(e.message); process.exit(0); }); + " || true + - name: Generate aggregate coverage report run: mvn verify -ntp -B -pl coverage-report -am -DskipTests shell: bash diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 24eb6d7..251b68a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -65,69 +65,6 @@ jobs: java-version: ${{ matrix.java-version }} maven-version: ${{ env.MAVEN_VERSION }} - integration-tests-cleanup: - name: Cleanup Integration Test Resources - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [integration-tests, sonarqube-scan] - if: always() - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Bind CF Services - uses: ./.github/actions/cf-bind - with: - cf-api: ${{ secrets.CF_API_AWS }} - cf-username: ${{ secrets.CF_USERNAME }} - cf-password: ${{ secrets.CF_PASSWORD }} - cf-org: ${{ secrets.CF_ORG_AWS }} - cf-space: ${{ secrets.CF_SPACE_AWS }} - - - name: Cleanup resource groups - working-directory: integration-tests - shell: bash - run: | - cds bind --exec -- node -e " - const https = require('https'); - const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); - const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); - if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } - const tokenUrl = key.url + '/oauth/token'; - const apiUrl = key.serviceurls.AI_API_URL; - function fetch(url, opts) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => { - let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); - }); - req.on('error',reject); - if(opts.body) req.write(opts.body); - req.end(); - }); - } - (async () => { - const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); - const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); - const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); - const token = JSON.parse(tokenRes.body).access_token; - const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - // Match ALL attempts for this run (not just current attempt) plus cds-itest- RGs - const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-']; - const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); - const groups = JSON.parse(rgRes.body).resources || []; - const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); - for (const rg of toDelete) { - const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); - console.log('Delete', rg.resourceGroupId, '->', res.status); - } - console.log('Cleaned up', toDelete.length, 'resource groups for run ' + runId + ' (all attempts)'); - })().catch(e => { console.error(e.message); process.exit(1); }); - " - local-mtx-tests: name: Local MTX Tests (Java ${{ matrix.java-version }}) runs-on: ubuntu-latest From d539f21e5820e56a3e540b0905f50b86b26cec96 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 11:12:39 +0200 Subject: [PATCH 11/43] fix(ci): scope resource group cleanup to own job only Each parallel CI job (Java 17, Java 21, SonarQube) was using broad prefixes in its cleanup step, deleting resource groups belonging to sibling jobs still in progress. This caused intermittent 403 Forbidden errors when the affected jobs tried to use their now-deleted resource groups. Narrow the cleanup prefixes so each job only deletes its own: - integration-tests: itest-{run_id}-{attempt}-j{version}* - scan-with-sonar: sonar-{run_id}-{attempt}* Both still clean up itest-rg-* (ResourceGroupTest leftovers). --- .github/actions/integration-tests/action.yml | 5 ++++- .github/actions/scan-with-sonar/action.yml | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index c023e11..b4e8415 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -65,7 +65,10 @@ runs: const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const attempt = '${{ github.run_attempt }}'; + const javaVersion = '${{ inputs.java-version }}'; + const ownPrefix = 'itest-' + runId + '-' + attempt + '-j' + javaVersion; + const prefixes = [ownPrefix, 'itest-rg-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index 86c3ded..af84a2c 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -78,7 +78,9 @@ runs: const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; const runId = '${{ github.run_id }}'; - const prefixes = ['itest-' + runId + '-', 'sonar-' + runId + '-', 'cds-itest-', 'itest-rg-']; + const attempt = '${{ github.run_attempt }}'; + const ownPrefix = 'sonar-' + runId + '-' + attempt; + const prefixes = [ownPrefix, 'itest-rg-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); From cdb396708a5fb7273a87bea06276d88efbe9b7b3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 11:56:06 +0200 Subject: [PATCH 12/43] test(ai-core): add unit tests for uncovered code paths - DeploymentHandler: test onCreate (with/without TTL) and onUpdate happy path (targetStatus and configurationId branches) - ResourceGroupHandler: test onUpdate with/without labels, buildTenantLabelSelector branches (tenantId filter, MT non-provider, MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches (provider, single tenant, wrong tenant, matching tenant) - AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl branch (with and without multi-tenancy), test detectMultiTenancy via services() for sidecarUrl branch and no-MT fallback --- .../core/AICoreServiceConfigurationTest.java | 177 +++++++++ .../core/handler/DeploymentHandlerTest.java | 133 ++++++- .../handler/ResourceGroupHandlerTest.java | 345 +++++++++++++++++- 3 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java new file mode 100644 index 0000000..d2feaaa --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -0,0 +1,177 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; +import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AICoreServiceConfigurationTest { + + @Mock private CdsRuntimeConfigurer configurer; + @Mock private CdsRuntime runtime; + @Mock private CdsEnvironment environment; + @Mock private ServiceCatalog serviceCatalog; + + /** + * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl + * (lines 116-123) with multi-tenancy disabled. + */ + @Test + void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false); + when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(mockService); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeastOnce()).eventHandler(captor.capture()); + + var handlers = captor.getAllValues(); + assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); + assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); + // No setup handler registered when MT is disabled + assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + } + + /** + * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with + * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121). + */ + @Test + void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true); + when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(mockService); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeastOnce()).eventHandler(captor.capture()); + + var handlers = captor.getAllValues(); + assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); + assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); + assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); + } + + /** + * Tests detectMultiTenancy returning true when sidecar URL is set (the + * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch). + * This is exercised through services() with no AI Core binding present. + */ + @Test + void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + org.junit.jupiter.api.Assumptions.assumeTrue( + envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + CdsProperties cdsProperties = new CdsProperties(); + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + cdsProperties.setMultiTenancy(mt); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert captor.getValue().isMultiTenancyEnabled(); + } + + /** + * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService. + */ + @Test + void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + org.junit.jupiter.api.Assumptions.assumeTrue( + envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getEnvironment()).thenReturn(environment); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + CdsProperties cdsProperties = new CdsProperties(); + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + // No URL set - defaults to null + mt.setSidecar(sidecar); + cdsProperties.setMultiTenancy(mt); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert !captor.getValue().isMultiTenancyEnabled(); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index fb4211b..cc26a3c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -5,21 +5,38 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -27,7 +44,8 @@ class DeploymentHandlerTest { @Mock private AICoreServiceImpl service; @Mock private DeploymentApi deploymentApi; - @Mock private CdsUpdateEventContext context; + @Mock private CdsUpdateEventContext updateContext; + @Mock private CdsCreateEventContext createContext; private DeploymentHandler cut; @@ -41,7 +59,7 @@ void setup() { void onUpdate_emptyEntries_throwsBadRequest() { List entries = List.of(); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -54,7 +72,7 @@ void onUpdate_emptyEntries_throwsBadRequest() { void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -63,4 +81,113 @@ void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { verifyNoInteractions(deploymentApi); } + + @Test + void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { + Deployments data = Deployments.create(); + data.setTargetStatus("STOPPED"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-123"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); + assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + } + + @Test + void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { + Deployments data = Deployments.create(); + data.setConfigurationId("config-456"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-789"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + } + + @Test + void onCreate_createsDeploymentWithConfigurationId() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-1"); + entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test")); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + verify(createContext).setResult(any(List.class)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-test"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + } + + @Test + void onCreate_withTtl_setsTtlOnRequest() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-2"); + entry.setTtl("PT24H"); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 2debb0d..14a828b 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -4,23 +4,44 @@ package com.sap.cds.feature.aicore.core.handler; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -28,7 +49,7 @@ class ResourceGroupHandlerTest { @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsCreateEventContext context; + @Mock private CdsCreateEventContext createContext; private ResourceGroupHandler handler; @@ -43,7 +64,7 @@ void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); @@ -62,7 +83,7 @@ void onCreate_withLabelsOnly_setsOnlyUserLabels() { List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); @@ -83,7 +104,7 @@ void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { List.of(Map.of("key", "env", "value", "prod"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); // Tenant label first, then user-supplied labels — and tenant label is NOT lost. @@ -105,7 +126,7 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getLabels()) @@ -113,6 +134,320 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); } + @Nested + @ExtendWith(MockitoExtension.class) + class OnUpdateTests { + + @Mock private CdsUpdateEventContext updateContext; + @Mock private CqnUpdate cqnUpdate; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void onUpdate_withLabels_callsPatchWithLabels() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("my-tenant"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("my-tenant"); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); + assertThat(captor.getValue().getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple("env", "staging")); + } + + @Test + void onUpdate_withoutLabels_callsPatchWithoutLabels() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + // no labels in update payload + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class BuildTenantLabelSelectorTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readAll_withTenantIdFilter_usesLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + values.put(ResourceGroups.TENANT_ID, "tenant-x"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x"); + } + + @Test + void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn("current-tenant"); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); + } + + @Test + void readAll_multiTenancy_nullTenant_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn(null); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + + @Test + void readAll_singleTenancy_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class EnsureOwnedByCurrentTenantTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readById_providerUser_allowsAccessToAnyRg() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(true); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-any"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-any")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_singleTenancy_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-single"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-single")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_multiTenancy_wrongTenant_throws404() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-other")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatThrownBy(() -> handler.onRead(readContext)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + } + + @Test + void readById_multiTenancy_matchingTenant_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(rg.getResourceGroupId()).thenReturn("rg-mine"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-mine")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + } + private BckndResourceGroupsPostRequest captureCreateRequest() { ArgumentCaptor captor = ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); From 6657df851691ff8fc5334ee17b9cb1b6fa0cbb52 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 12:13:43 +0200 Subject: [PATCH 13/43] fix(ci): include cds-itest- prefix in resource group cleanup The MultiTenancyTest creates per-tenant resource groups with names like cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant name 'itest-*'). These are unique per test run (timestamped) and safe to clean up from any job without cross-job interference. --- .github/actions/integration-tests/action.yml | 2 +- .github/actions/scan-with-sonar/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index b4e8415..0bc0bf7 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -68,7 +68,7 @@ runs: const attempt = '${{ github.run_attempt }}'; const javaVersion = '${{ inputs.java-version }}'; const ownPrefix = 'itest-' + runId + '-' + attempt + '-j' + javaVersion; - const prefixes = [ownPrefix, 'itest-rg-']; + const prefixes = [ownPrefix, 'itest-rg-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index af84a2c..fcc37bd 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -80,7 +80,7 @@ runs: const runId = '${{ github.run_id }}'; const attempt = '${{ github.run_attempt }}'; const ownPrefix = 'sonar-' + runId + '-' + attempt; - const prefixes = [ownPrefix, 'itest-rg-']; + const prefixes = [ownPrefix, 'itest-rg-', 'cds-itest-']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); From 6e28a9d4d4be92c6599f2ef14cb9597e1f400f63 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:41:33 +0200 Subject: [PATCH 14/43] refactor(ai-core): migrate AICoreService from CqnService to RemoteService --- .../com/sap/cds/feature/aicore/api/AICoreService.java | 6 +++--- .../cds/feature/aicore/core/AbstractAICoreService.java | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 40374bb..2955b33 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -3,7 +3,7 @@ */ package com.sap.cds.feature.aicore.api; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; /** @@ -25,10 +25,10 @@ *

The implementation is tenant-aware: it reads the current tenant from the {@code * RequestContext}. Callers do not need to pass tenant identifiers explicitly. */ -public interface AICoreService extends CqnService { +public interface AICoreService extends RemoteService { /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore"; + String DEFAULT_NAME = "AICore$Default"; /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ String RESOURCE_GROUPS = "AICore.resourceGroups"; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 0e7ec79..14c921f 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; @@ -22,6 +23,14 @@ protected AbstractAICoreService(String name, CdsRuntime runtime) { super(name, runtime); } + /** The qualified CDS service definition name used for model lookups. */ + private static final String CDS_DEFINITION_NAME = "AICore"; + + @Override + public CdsService getDefinition() { + return RequestContext.getCurrent(runtime).getModel().getService(CDS_DEFINITION_NAME); + } + /** Returns the {@link CdsRuntime} that this service was created with. */ public CdsRuntime getRuntime() { return runtime; From 42bcdf5b7de2134b0a5d34879876cfa61f35e276 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:43:01 +0200 Subject: [PATCH 15/43] refactor(ai-core): delete AICoreApplicationServiceHandler --- .../core/AICoreServiceConfiguration.java | 3 - .../AICoreApplicationServiceHandler.java | 103 ------------------ .../core/AICoreServiceConfigurationTest.java | 3 - .../ApplicationServiceDelegationTest.java | 41 ------- integration-tests/spring/test-service.cds | 6 +- 5 files changed, 1 insertion(+), 155 deletions(-) delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java delete mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index bfd4c8c..b7ec08a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -8,7 +8,6 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; @@ -106,7 +105,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new DeploymentHandler(service)); configurer.eventHandler(new ConfigurationHandler(service)); configurer.eventHandler(new ActionHandler(service)); - configurer.eventHandler(new AICoreApplicationServiceHandler(service)); logger.debug("Registered Prod AI-Core Implementation"); if (service.isMultiTenancyEnabled()) { @@ -115,7 +113,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } else if (registered instanceof MockAICoreServiceImpl mockService) { configurer.eventHandler(new MockEntityHandler()); - configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); if (mockService.isMultiTenancyEnabled()) { configurer.eventHandler(new MockAICoreSetupHandler(mockService)); logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java deleted file mode 100644 index 1b340cd..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core.handler; - -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnStructuredTypeRef; -import com.sap.cds.ql.cqn.Modifier; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.cds.ApplicationService; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsDeleteEventContext; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.HandlerOrder; -import com.sap.cds.services.handler.annotations.On; -import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.utils.OrderConstants; - -/** - * Intercepts CRUD events on application service entities that are projections on AICore entities - * and delegates them to the AICore service. Without this, the framework would try to forward these - * to the PersistenceService, which fails since AICore entities have no database tables. - */ -@ServiceName(value = "*", type = ApplicationService.class) -public class AICoreApplicationServiceHandler implements EventHandler { - - private final CqnService aiCoreService; - - public AICoreApplicationServiceHandler(CqnService aiCoreService) { - this.aiCoreService = aiCoreService; - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onRead(CdsReadEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - CqnSelect rewritten = CQL.copy(context.getCqn(), entityModifier(sourceEntity)); - context.setResult(aiCoreService.run(rewritten)); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onCreate(CdsCreateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onUpdate(CdsUpdateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onDelete(CdsDeleteEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - private String resolveAICoreSource(CdsEntity entity, CdsModel model) { - if (entity == null || !entity.isProjection()) { - return null; - } - return entity - .query() - .filter(q -> q.from().isRef()) - .map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity()) - .map(CdsEntity::getQualifiedName) - .filter(name -> name.startsWith("AICore.")) - .orElse(null); - } - - private static Modifier entityModifier(String targetEntity) { - return new Modifier() { - @Override - public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { - var copy = CQL.copy(ref); - copy.rootSegment().id(targetEntity); - return copy.build(); - } - }; - } -} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index d2feaaa..def38af 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -11,7 +11,6 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; @@ -63,7 +62,6 @@ void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { var handlers = captor.getAllValues(); assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); // No setup handler registered when MT is disabled assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); } @@ -97,7 +95,6 @@ void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { var handlers = captor.getAllValues(); assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java deleted file mode 100644 index c8f1296..0000000 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.itest; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.Test; -import org.springframework.security.test.context.support.WithMockUser; - -class ApplicationServiceDelegationTest extends BaseIntegrationTest { - - @Test - @WithMockUser(username = "test-user") - void readConfigurations_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Configurations")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readDeployments_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Deployments")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readResourceGroups_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/ResourceGroups")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } -} diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index ecf43c6..520066d 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -1,11 +1,7 @@ using {itest} from '../db/schema'; -using { AICore } from 'com.sap.cds/ai'; service TestService { - entity Products as projection on itest.Products; - entity Configurations as projection on AICore.configurations; - entity Deployments as projection on AICore.deployments; - entity ResourceGroups as projection on AICore.resourceGroups; + entity Products as projection on itest.Products; } service RecommendationTestService @(requires: 'any') { From fa28cda69da7de87c489849919241ec4ed5574a7 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 15:48:50 +0200 Subject: [PATCH 16/43] fix(ai-core): guard service registration on AICore model presence --- .../feature/aicore/core/AICoreServiceConfiguration.java | 9 +++++++++ .../cds/feature/aicore/core/AbstractAICoreService.java | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index b7ec08a..dd2d5df 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -36,6 +36,10 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService("AICore").isPresent(); + } + private static boolean hasAICoreBinding(CdsRuntime runtime) { boolean hasServiceBinding = runtime @@ -69,6 +73,11 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); + if (!hasAICoreModel(runtime)) { + logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + return; + } + boolean hasBinding = hasAICoreBinding(runtime); boolean multiTenancyEnabled = detectMultiTenancy(runtime); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 14c921f..9471c52 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -28,7 +28,7 @@ protected AbstractAICoreService(String name, CdsRuntime runtime) { @Override public CdsService getDefinition() { - return RequestContext.getCurrent(runtime).getModel().getService(CDS_DEFINITION_NAME); + return runtime.getCdsModel().findService(CDS_DEFINITION_NAME).orElse(null); } /** Returns the {@link CdsRuntime} that this service was created with. */ From ae852881076e2d7f122cddea7622e48b3cf2c6db Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 16:00:45 +0200 Subject: [PATCH 17/43] fix(test): mock CdsModel in unit tests and restore AICore model import --- .../aicore/core/AICoreServiceConfigurationTest.java | 10 ++++++++++ integration-tests/spring/test-service.cds | 1 + 2 files changed, 11 insertions(+) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index def38af..cf3fb75 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -7,17 +7,21 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.Optional; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -110,6 +114,9 @@ void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); @@ -146,6 +153,9 @@ void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getEnvironment()).thenReturn(environment); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index 520066d..232f698 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -1,4 +1,5 @@ using {itest} from '../db/schema'; +using { AICore } from 'com.sap.cds/ai'; service TestService { entity Products as projection on itest.Products; From 62ba1d7bddc88ed8453e3afccec3e3a765baeae0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 16:51:11 +0200 Subject: [PATCH 18/43] fix(ai-core): extend AbstractCdsDefinedService for proper RemoteService support --- cds-feature-ai-core/pom.xml | 1 - .../aicore/core/AbstractAICoreService.java | 17 ++++++----------- .../core/AICoreServiceConfigurationTest.java | 8 ++++++++ .../core/AICoreServiceImplDeploymentIdTest.java | 8 ++++++++ .../aicore/core/MockAICoreServiceImplTest.java | 8 ++++++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index c49b701..26247a1 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -44,7 +44,6 @@ com.sap.cds cds-services-impl - test diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 9471c52..2e4fe7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -4,11 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.reflect.CdsService; +import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.utils.services.AbstractCqnService; import io.github.resilience4j.retry.Retry; import java.util.Map; @@ -17,18 +16,14 @@ * cache access, configuration, and resource group resolution. These methods are not part of the * public {@link AICoreService} contract but are shared between the real and mock implementations. */ -public abstract class AbstractAICoreService extends AbstractCqnService implements AICoreService { +public abstract class AbstractAICoreService extends AbstractCdsDefinedService + implements AICoreService { - protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, runtime); - } - - /** The qualified CDS service definition name used for model lookups. */ + /** The qualified CDS service definition name. */ private static final String CDS_DEFINITION_NAME = "AICore"; - @Override - public CdsService getDefinition() { - return runtime.getCdsModel().findService(CDS_DEFINITION_NAME).orElse(null); + protected AbstractAICoreService(String name, CdsRuntime runtime) { + super(name, CDS_DEFINITION_NAME, runtime); } /** Returns the {@link CdsRuntime} that this service was created with. */ diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index cf3fb75..074a2f8 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -46,6 +46,9 @@ void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) @@ -79,6 +82,9 @@ void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) @@ -117,6 +123,7 @@ void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { CdsModel cdsModel = mock(CdsModel.class); when(runtime.getCdsModel()).thenReturn(cdsModel); when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); @@ -156,6 +163,7 @@ void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { CdsModel cdsModel = mock(CdsModel.class); when(runtime.getCdsModel()).thenReturn(cdsModel); when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(environment.getServiceBindings()).thenReturn(Stream.empty()); lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) .thenAnswer(invocation -> invocation.getArgument(2)); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index f5e3396..be6e5de 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -24,6 +24,8 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; @@ -67,6 +69,9 @@ void setUp() { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); // Use small retry counts so failures don't slow tests. when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) .thenReturn(1); @@ -198,6 +203,9 @@ void resourceGroupForTenant_nullTenantId_returnsDefault() { CdsRuntime rtMt = mock(CdsRuntime.class); CdsEnvironment envMt = mock(CdsEnvironment.class); when(rtMt.getEnvironment()).thenReturn(envMt); + CdsModel cdsModelMt = mock(CdsModel.class); + when(rtMt.getCdsModel()).thenReturn(cdsModelMt); + when(cdsModelMt.getService("AICore")).thenReturn(mock(CdsService.class)); when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index a8dd214..0dd1660 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -10,6 +10,8 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.Test; @@ -20,6 +22,9 @@ private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("test-rg"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) @@ -32,6 +37,9 @@ void defaultConstructor_setsMultiTenancyFalse() { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) From 96452bd7645d1556d22cd5e562b1e83e552637a8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 11 Jun 2026 17:44:35 +0200 Subject: [PATCH 19/43] fix(recommendations): promote cds-services-impl to compile scope --- cds-feature-recommendations/pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml index 4d7f75e..b86d41b 100644 --- a/cds-feature-recommendations/pom.xml +++ b/cds-feature-recommendations/pom.xml @@ -49,7 +49,6 @@ com.sap.cds cds-services-impl - test From 2f60ca8ea1f8d7716ee6bc2c5f3a4093173a2059 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 08:54:08 +0200 Subject: [PATCH 20/43] refactor(test): use real CdsRuntime in AICoreServiceConfigurationTest --- cds-feature-ai-core/pom.xml | 8 + .../core/AICoreServiceConfigurationTest.java | 214 +++++------------- 2 files changed, 63 insertions(+), 159 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index 26247a1..ac235b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -49,6 +49,14 @@ ${project.artifactId} + + + src/test/resources + + + src/gen/srv/src/main/resources + + com.sap.cds diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index 074a2f8..7dd772c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -3,190 +3,86 @@ */ package com.sap.cds.feature.aicore.core; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -import com.sap.cds.services.ServiceCatalog; -import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.environment.CdsProperties; -import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import java.util.Optional; -import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Tests {@link AICoreServiceConfiguration} using a real {@link CdsRuntime} booted with the AICore + * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy + * Mockito mocks. + * + *

Tests that exercise service registration are skipped when {@code AICORE_SERVICE_KEY} is set, + * because the configuration would then register the real {@link AICoreServiceImpl} (which requires + * actual AI Core credentials). + */ class AICoreServiceConfigurationTest { - @Mock private CdsRuntimeConfigurer configurer; - @Mock private CdsRuntime runtime; - @Mock private CdsEnvironment environment; - @Mock private ServiceCatalog serviceCatalog; - - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl - * (lines 116-123) with multi-tenancy disabled. - */ - @Test - void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - // No setup handler registered when MT is disabled - assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + private static void assumeNoAICoreBinding() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + Assumptions.assumeTrue(envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); } - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with - * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121). - */ @Test - void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); + void noBinding_noMultiTenancy_registersMockService() { + assumeNoAICoreBinding(); + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isFalse(); } - /** - * Tests detectMultiTenancy returning true when sidecar URL is set (the - * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch). - * This is exercised through services() with no AI Core binding present. - */ @Test - void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); + void noBinding_withSidecarUrl_registersMultiTenantMockService() { + assumeNoAICoreBinding(); + + CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); sidecar.setUrl("http://localhost:4004"); mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); + props.setMultiTenancy(mt); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert captor.getValue().isMultiTenancyEnabled(); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isTrue(); } - /** - * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService. - */ @Test - void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.findService("AICore")).thenReturn(Optional.of(mock(CdsService.class))); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); - CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); - CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); - // No URL set - defaults to null - mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); - when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null); + void noModel_skipsServiceRegistration() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert !captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNull(); } } From 57d0d72e49e53f5517190e069d3ac9cee492325f Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:00:04 +0200 Subject: [PATCH 21/43] refactor(ai-core): remove AICORE_SERVICE_KEY env var check from binding detection --- .../core/AICoreServiceConfiguration.java | 18 ++++++------------ .../core/AICoreServiceConfigurationTest.java | 15 ++------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index dd2d5df..43d9127 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -41,18 +41,12 @@ private static boolean hasAICoreModel(CdsRuntime runtime) { } private static boolean hasAICoreBinding(CdsRuntime runtime) { - boolean hasServiceBinding = - runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - if (hasServiceBinding) { - return true; - } - String envKey = System.getenv("AICORE_SERVICE_KEY"); - return envKey != null && !envKey.isBlank(); + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); } /** diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index 7dd772c..77f7a2c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -10,7 +10,6 @@ import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; /** @@ -18,21 +17,13 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Tests that exercise service registration are skipped when {@code AICORE_SERVICE_KEY} is set, - * because the configuration would then register the real {@link AICoreServiceImpl} (which requires - * actual AI Core credentials). + *

Since the test runtime has no service bindings, the configuration always registers a {@link + * MockAICoreServiceImpl} regardless of environment variables. */ class AICoreServiceConfigurationTest { - private static void assumeNoAICoreBinding() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - Assumptions.assumeTrue(envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - } - @Test void noBinding_noMultiTenancy_registersMockService() { - assumeNoAICoreBinding(); - CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) .cdsModel("edmx/csn.json") @@ -49,8 +40,6 @@ void noBinding_noMultiTenancy_registersMockService() { @Test void noBinding_withSidecarUrl_registersMultiTenantMockService() { - assumeNoAICoreBinding(); - CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); From cdd70d5073ff8d28b73a10df86c95fcfe6ace5fd Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:04:51 +0200 Subject: [PATCH 22/43] chore: exclude Mock* classes from SonarQube coverage --- pom.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pom.xml b/pom.xml index ac2ecb8..e8de8c6 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,9 @@ 1.0.0-SNAPSHOT + + **/Mock*.java + 17 From c50ced33ce3d9776bba97e2f5a1f27b7cd0c8ff8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 09:08:07 +0200 Subject: [PATCH 23/43] refactor(test): use real CdsRuntime in AICoreServiceImplDeploymentIdTest --- .../AICoreServiceImplDeploymentIdTest.java | 94 ++++++++++--------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index be6e5de..4dc62c2 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -24,12 +25,14 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -54,33 +57,28 @@ class AICoreServiceImplDeploymentIdTest { new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); private String cacheKey() { - // Derive via the production helper rather than hardcoding RG + "::" + CONFIG_NAME so a - // change to the cache-key format is caught here instead of silently passing wrong-path. return AICoreServiceImpl.deploymentCacheKey(RG, spec); } + /** Boots a real CdsRuntime with the AICore model and fast retry settings. */ + private static CdsRuntime createTestRuntime() { + TestPropertiesProvider props = new TestPropertiesProvider(); + props.setProperty("cds.ai.core.maxRetries", 1); + props.setProperty("cds.ai.core.initialDelayMs", 1L); + + return CdsRuntimeConfigurer.create(props) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .complete(); + } + @BeforeEach void setUp() { deploymentApi = mock(DeploymentApi.class); configurationApi = mock(ConfigurationApi.class); resourceGroupApi = mock(ResourceGroupApi.class); - AiCoreService sdkService = mock(AiCoreService.class); - - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - // Use small retry counts so failures don't slow tests. - when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) - .thenReturn(1); - when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) - .thenReturn(1L); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + + CdsRuntime runtime = createTestRuntime(); service = new AICoreServiceImpl( @@ -90,7 +88,7 @@ void setUp() { deploymentApi, configurationApi, resourceGroupApi, - sdkService); + mock(AiCoreService.class)); } @Test @@ -136,15 +134,12 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { @Test void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { - // Transient 5xx must NOT invalidate a potentially valid cache entry. The exception is - // propagated so the caller's retry/backoff policy can handle it. service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec)) - .isSameAs(serverError); + assertThatThrownBy(() -> service.deploymentId(RG, spec)).isSameAs(serverError); assertThat(service.getResourceGroupDeploymentCache()) .containsEntry(cacheKey(), "still-valid-id"); @@ -191,7 +186,6 @@ void secondCallUsesCachedResult_singleQueryToApi() { assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); - // First call queries, second call hits the cache and only verifies via get. verify(deploymentApi, times(1)) .query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()); verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); @@ -199,24 +193,12 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - // Even with MT enabled, a null tenantId should fall back to the default resource group. - CdsRuntime rtMt = mock(CdsRuntime.class); - CdsEnvironment envMt = mock(CdsEnvironment.class); - when(rtMt.getEnvironment()).thenReturn(envMt); - CdsModel cdsModelMt = mock(CdsModel.class); - when(rtMt.getCdsModel()).thenReturn(cdsModelMt); - when(cdsModelMt.getService("AICore")).thenReturn(mock(CdsService.class)); - when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); - when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); - when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("my-default"); - when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + CdsRuntime runtime = createTestRuntime(); AICoreServiceImpl mtService = new AICoreServiceImpl( AICoreService.DEFAULT_NAME, - rtMt, + runtime, true, // multi-tenancy enabled deploymentApi, configurationApi, @@ -224,25 +206,22 @@ void resourceGroupForTenant_nullTenantId_returnsDefault() { mock(AiCoreService.class)); String result = mtService.resourceGroupForTenant(null); - assertThat(result).isEqualTo("my-default"); + assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - // Single-tenancy always returns default regardless of the tenantId passed. String result = service.resourceGroupForTenant("any-tenant"); assertThat(result).isEqualTo("default"); } @Test void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { - // Empty deployment list → falls through to create path. AiDeploymentList emptyList = mock(AiDeploymentList.class); when(emptyList.getResources()).thenReturn(List.of()); when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(emptyList); - // Existing config with the same name, so createConfiguration is skipped. AiConfigurationList configList = mock(AiConfigurationList.class); var existingConfig = mock(com.sap.ai.sdk.core.model.AiConfiguration.class); when(existingConfig.getId()).thenReturn("cfg-1"); @@ -266,4 +245,27 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { verify(configurationApi, never()).create(any(), any()); verify(deploymentApi).create(eq(RG), any()); } + + /** Properties provider that allows overriding specific keys for test configuration. */ + private static class TestPropertiesProvider extends SimplePropertiesProvider { + private final Map properties = new HashMap<>(); + + TestPropertiesProvider() { + super(new CdsProperties()); + } + + void setProperty(String key, Object value) { + properties.put(key, value); + } + + @Override + @SuppressWarnings("unchecked") + public T getProperty(String key, Class asClazz, T defaultValue) { + Object value = properties.get(key); + if (value != null && asClazz.isInstance(value)) { + return (T) value; + } + return defaultValue; + } + } } From 70724c5c40ef2bb6ee9579ec59807d3ef3cd0f3b Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 12:48:09 +0200 Subject: [PATCH 24/43] fix: remove duplicate detectMultiTenancy method from merge --- .../aicore/core/AICoreServiceConfiguration.java | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 4c1d7b2..43d9127 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -63,20 +63,6 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; } - /** - * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} - * property or the presence of a {@link DeploymentService} in the service catalog. This aligns - * with the standard CAP Java convention — no custom property flag is needed. - */ - private static boolean detectMultiTenancy(CdsRuntime runtime) { - CdsProperties props = runtime.getEnvironment().getCdsProperties(); - String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); - if (sidecarUrl != null && !sidecarUrl.isBlank()) { - return true; - } - return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; - } - @Override public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); From 7adcce0091741b80a498ddf3216aaa03afecffa4 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 13:45:55 +0200 Subject: [PATCH 25/43] refactor(ai-core): rename DEFAULT_NAME to AICoreService$Default Follow standard CAP Java naming convention for service instances (ServiceInterface$Default). The CDS definition name stays 'AICore' (matching the CDS model); only the registered instance name changes. --- .../main/java/com/sap/cds/feature/aicore/api/AICoreService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 2955b33..3546bea 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -28,7 +28,7 @@ public interface AICoreService extends RemoteService { /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore$Default"; + String DEFAULT_NAME = "AICoreService$Default"; /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ String RESOURCE_GROUPS = "AICore.resourceGroups"; From 168aa714bbe601b95b7b5ee3c341ef935fef2750 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 13:46:57 +0200 Subject: [PATCH 26/43] refactor(ai-core): define EventContext subinterfaces for programmatic API Add typed EventContext interfaces in the api package for the three programmatic API methods: - DeploymentIdContext: for deploymentId(resourceGroupId, spec) - InferenceClientContext: for inferenceClient(resourceGroupId, deploymentId) - ResourceGroupContext: for resourceGroup() These enable the idiomatic CAP pattern where service methods emit events and ON handlers provide the implementation, allowing extensibility via @Before/@After hooks. --- .../aicore/api/DeploymentIdContext.java | 44 +++++++++++++++++++ .../aicore/api/InferenceClientContext.java | 44 +++++++++++++++++++ .../aicore/api/ResourceGroupContext.java | 34 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java new file mode 100644 index 0000000..4fa04d9 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code deploymentId} event. + * + *

Emitted by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} to resolve (or + * create) a deployment matching the given spec inside the given resource group. The ON handler + * performs cache lookup, retry, configuration creation, deployment creation and polling. + */ +@EventName(DeploymentIdContext.EVENT) +public interface DeploymentIdContext extends EventContext { + + /** Event name constant. */ + String EVENT = "deploymentId"; + + /** Returns the resource group ID to operate in. */ + String getResourceGroupId(); + + /** Sets the resource group ID to operate in. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment specification. */ + ModelDeploymentSpec getSpec(); + + /** Sets the deployment specification. */ + void setSpec(ModelDeploymentSpec spec); + + /** Returns the resolved deployment ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved deployment ID. */ + void setResult(String deploymentId); + + /** Creates a new context instance. */ + static DeploymentIdContext create() { + return EventContext.create(DeploymentIdContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java new file mode 100644 index 0000000..1b3095e --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; + +/** + * Typed {@link EventContext} for the {@code inferenceClient} event. + * + *

Emitted by {@link AICoreService#inferenceClient(String, String)} to build an {@link ApiClient} + * preconfigured with the inference destination for the given deployment. + */ +@EventName(InferenceClientContext.EVENT) +public interface InferenceClientContext extends EventContext { + + /** Event name constant. */ + String EVENT = "inferenceClient"; + + /** Returns the resource group ID containing the deployment. */ + String getResourceGroupId(); + + /** Sets the resource group ID containing the deployment. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment ID. */ + String getDeploymentId(); + + /** Sets the deployment ID. */ + void setDeploymentId(String deploymentId); + + /** Returns the configured {@link ApiClient} (set by the ON handler). */ + ApiClient getResult(); + + /** Sets the configured {@link ApiClient}. */ + void setResult(ApiClient client); + + /** Creates a new context instance. */ + static InferenceClientContext create() { + return EventContext.create(InferenceClientContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java new file mode 100644 index 0000000..6581645 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -0,0 +1,34 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code resourceGroup} event. + * + *

Emitted by {@link AICoreService#resourceGroup()} to resolve the AI Core resource group ID for + * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not + * exist. In single-tenancy mode, the configured default resource group is returned. + * + *

The current tenant is read from the {@code RequestContext} — no explicit input is required. + */ +@EventName(ResourceGroupContext.EVENT) +public interface ResourceGroupContext extends EventContext { + + /** Event name constant. */ + String EVENT = "resourceGroup"; + + /** Returns the resolved resource group ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved resource group ID. */ + void setResult(String resourceGroupId); + + /** Creates a new context instance. */ + static ResourceGroupContext create() { + return EventContext.create(ResourceGroupContext.class, null); + } +} From f85100a3e7bb7a6db479e20d379fa3834e3847b4 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 14:04:13 +0200 Subject: [PATCH 27/43] refactor(ai-core): make service API methods emit events; move logic to handler Apply idiomatic CAP Java pattern: service methods create typed EventContext, emit(), and return result. A separately-registered AICoreApiHandler provides the ON implementation with the actual business logic. - AICoreServiceImpl.deploymentId/inferenceClient/resourceGroup/ resourceGroupForTenant now emit typed contexts instead of doing work directly - New AICoreApiHandler handles DeploymentIdContext, InferenceClientContext, ResourceGroupContext with all caching, retry, and SDK logic - ResourceGroupContext extended with optional tenantId for explicit-tenant path - AICoreServiceConfiguration registers AICoreApiHandler - AICoreServiceImpl retains shared state (caches, config, APIs) accessed by handlers via EventContext.getService() This enables extensibility: apps can register @Before/@After handlers on deploymentId, inferenceClient, and resourceGroup events. --- .../aicore/api/ResourceGroupContext.java | 12 +- .../core/AICoreServiceConfiguration.java | 2 + .../aicore/core/AICoreServiceImpl.java | 287 +++++----------- .../aicore/core/handler/AICoreApiHandler.java | 305 ++++++++++++++++++ .../AICoreServiceImplDeploymentIdTest.java | 54 ++-- 5 files changed, 418 insertions(+), 242 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java index 6581645..19a2a82 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -13,7 +13,8 @@ * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not * exist. In single-tenancy mode, the configured default resource group is returned. * - *

The current tenant is read from the {@code RequestContext} — no explicit input is required. + *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the + * current tenant is read from the {@code RequestContext}. */ @EventName(ResourceGroupContext.EVENT) public interface ResourceGroupContext extends EventContext { @@ -21,6 +22,15 @@ public interface ResourceGroupContext extends EventContext { /** Event name constant. */ String EVENT = "resourceGroup"; + /** + * Returns the explicit tenant ID (optional). If {@code null}, the handler reads the tenant from + * the current {@code RequestContext}. + */ + String getTenantId(); + + /** Sets an explicit tenant ID. */ + void setTenantId(String tenantId); + /** Returns the resolved resource group ID (set by the ON handler). */ String getResult(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 43d9127..bd9b4cd 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -8,6 +8,7 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; @@ -104,6 +105,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); if (registered instanceof AICoreServiceImpl service) { + configurer.eventHandler(new AICoreApiHandler()); configurer.eventHandler(new ResourceGroupHandler(service)); configurer.eventHandler(new DeploymentHandler(service)); configurer.eventHandler(new ConfigurationHandler(service)); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 52eeb4f..c51c8d2 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -9,21 +9,11 @@ import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.ai.sdk.core.model.AiConfigurationBaseData; -import com.sap.ai.sdk.core.model.AiConfigurationList; -import com.sap.ai.sdk.core.model.AiDeployment; -import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; -import com.sap.ai.sdk.core.model.AiDeploymentList; -import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; -import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; -import com.sap.ai.sdk.core.model.BckndResourceGroupList; -import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; @@ -32,12 +22,8 @@ import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; import java.time.Duration; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Production implementation of {@link AICoreService} backed by an SAP AI Core service binding. @@ -47,14 +33,12 @@ * results, deployment IDs and per-cache-key locks are cached in bounded {@link Caffeine} caches so * repeated calls within a tenant or resource group avoid round-trips to AI Core. * - *

Most state-changing AI Core calls are wrapped in a Resilience4j {@link Retry} that retries - * known transient errors (HTTP 403/404/412, see {@link #notReadyYet(OpenApiRequestException)}) with - * exponential backoff capped at 30 seconds. + *

The API methods ({@link #resourceGroup()}, {@link #deploymentId(String, ModelDeploymentSpec)}, + * {@link #inferenceClient(String, String)}) are thin emitters that delegate to registered ON + * handlers via the CAP event mechanism. */ public class AICoreServiceImpl extends AbstractAICoreService { - private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); - public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; private static final String DEFAULT_RESOURCE_GROUP = "default"; @@ -123,74 +107,47 @@ private static Cache newCache() { .build(); } + // ────────────────────────────────────────────────────────────────────────── + // Thin API methods — emit EventContext and return the handler's result + // ────────────────────────────────────────────────────────────────────────── + + @Override + public String resourceGroup() { + ResourceGroupContext ctx = ResourceGroupContext.create(); + emit(ctx); + return ctx.getResult(); + } + @Override public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled || tenantId == null) { - logger.debug("Using default resource group {}", defaultResourceGroup); - return defaultResourceGroup; - } - return getOrCreateResourceGroupForTenant(tenantId); + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + emit(ctx); + return ctx.getResult(); } @Override public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - String cacheKey = deploymentCacheKey(resourceGroupId, spec); - Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); - synchronized (lock) { - String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey); - if (cached != null) { - try { - var current = deploymentApi.get(resourceGroupId, cached); - if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) - || AiDeploymentStatus.PENDING.equals(current.getStatus())) { - return cached; - } - } catch (OpenApiRequestException e) { - // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry - // and fall through to discover or create a new one. Any other status (5xx, 401, 412, - // network errors, …) is propagated so the caller's retry/backoff policy can handle it - // rather than silently invalidating a potentially valid cache entry and triggering a - // duplicate deployment. - Integer status = e.statusCode(); - if (status == null || status != 404) { - throw e; - } - logger.debug( - "Cached deployment {} in resource group {} no longer exists (404), " - + "invalidating cache entry", - cached, - resourceGroupId); - } - resourceGroupDeploymentCache.invalidate(cacheKey); - } - AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec); - Optional existing = - deploymentList.getResources().stream() - .filter( - d -> - spec.configurationName().equals(d.getConfigurationName()) - && spec.matchesExisting().test(d) - && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) - || AiDeploymentStatus.PENDING.equals(d.getStatus()))) - .findFirst() - .map(AiDeployment::getId); - if (existing.isPresent()) { - String deploymentId = existing.get(); - resourceGroupDeploymentCache.put(cacheKey, deploymentId); - return deploymentId; - } - return createDeployment(resourceGroupId, spec, cacheKey); - } + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(resourceGroupId); + ctx.setSpec(spec); + emit(ctx); + return ctx.getResult(); } @Override public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - var destination = - sdkService.getInferenceDestination(resourceGroupId).usingDeploymentId(deploymentId); - logger.debug("Inference destination URI: {}", destination.getUri()); - return ApiClient.create(destination); + InferenceClientContext ctx = InferenceClientContext.create(); + ctx.setResourceGroupId(resourceGroupId); + ctx.setDeploymentId(deploymentId); + emit(ctx); + return ctx.getResult(); } + // ────────────────────────────────────────────────────────────────────────── + // Shared state accessors (used by handlers) + // ────────────────────────────────────────────────────────────────────────── + @Override public boolean isMultiTenancyEnabled() { return multiTenancyEnabled; @@ -233,6 +190,38 @@ public ResourceGroupApi getResourceGroupApi() { return resourceGroupApi; } + public AiCoreService getSdkService() { + return sdkService; + } + + public ConcurrentHashMap getDeploymentLocks() { + return deploymentLocks; + } + + public int getMaxRetries() { + return maxRetries; + } + + public long getInitialDelayMs() { + return initialDelayMs; + } + + /** + * Returns the underlying Caffeine cache for tenant-to-resource-group mappings. Exposed for use by + * the {@code AICoreApiHandler} which needs the atomic {@code get(key, loader)} method. + */ + public Cache getTenantResourceGroupCaffeineCache() { + return tenantResourceGroupCache; + } + + /** + * Returns the underlying Caffeine cache for resource-group-to-deployment mappings. Exposed for + * use by the {@code AICoreApiHandler} which needs direct cache operations. + */ + public Cache getResourceGroupDeploymentCaffeineCache() { + return resourceGroupDeploymentCache; + } + @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { @@ -258,146 +247,20 @@ public void clearTenantCache(String tenantId) { } } + // ────────────────────────────────────────────────────────────────────────── + // Static helpers + // ────────────────────────────────────────────────────────────────────────── + /** * Builds the cache key for the {@code resourceGroupDeploymentCache} and {@code deploymentLocks} - * maps. Package-private so tests can derive the same key the production code uses, instead of - * duplicating the format inline. + * maps. Public so that handlers and tests can derive the same key the production code uses, + * instead of duplicating the format inline. */ - static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + public static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { return resourceGroupId + "::" + spec.configurationName(); } - private String getOrCreateResourceGroupForTenant(String tenantId) { - return tenantResourceGroupCache.get( - tenantId, - key -> { - List labelSelector = List.of(TENANT_LABEL_KEY + "=" + key); - BckndResourceGroupList result = - resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - return resources.get(0).getResourceGroupId(); - } - String resourceGroupId = resourceGroupPrefix + key; - BckndResourceGroupLabel label = - BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(key); - BckndResourceGroupsPostRequest request = - BckndResourceGroupsPostRequest.create() - .resourceGroupId(resourceGroupId) - .labels(List.of(label)); - try { - resourceGroupApi.create(request); - logger.debug("Created resource group {} for tenant {}", resourceGroupId, key); - } catch (OpenApiRequestException e) { - if (e.statusCode() != null && e.statusCode() == 409) { - logger.debug( - "Resource group {} already exists (409 Conflict), reusing", resourceGroupId); - } else { - throw e; - } - } - return resourceGroupId; - }); - } - - private String createDeployment( - String resourceGroupId, ModelDeploymentSpec spec, String cacheKey) { - AiConfigurationList configList = - configurationApi.query( - resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); - String configId = - configList.getResources().stream() - .filter(c -> spec.configurationName().equals(c.getName())) - .findFirst() - .map( - c -> { - logger.debug( - "Reusing existing configuration {} ({}) in resource group {}", - c.getId(), - spec.configurationName(), - resourceGroupId); - return c.getId(); - }) - .orElseGet(() -> createConfiguration(resourceGroupId, spec)); - - return Retry.decorateSupplier( - retry, - () -> { - var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); - var deployResponse = deploymentApi.create(resourceGroupId, deployRequest); - String deploymentId = deployResponse.getId(); - logger.debug( - "Created deployment {} ({}) in resource group {}, polling for RUNNING", - deploymentId, - spec.configurationName(), - resourceGroupId); - return pollUntilRunning(resourceGroupId, deploymentId, cacheKey); - }) - .get(); - } - - private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { - AiConfigurationBaseData configRequest = - AiConfigurationBaseData.create() - .name(spec.configurationName()) - .executableId(spec.executableId()) - .scenarioId(spec.scenarioId()) - .parameterBindings(spec.parameterBindings()); - String configId = configurationApi.create(resourceGroupId, configRequest).getId(); - logger.debug( - "Created configuration {} ({}) in resource group {}", - configId, - spec.configurationName(), - resourceGroupId); - return configId; - } - - private String pollUntilRunning(String resourceGroupId, String deploymentId, String cacheKey) { - Retry pollRetry = - Retry.of( - "pollDeployment", - RetryConfig.custom() - .maxAttempts(maxRetries) - .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) - .retryOnResult( - deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) - .retryOnException(e -> false) - .build()); - - AiDeploymentResponseWithDetails result = - Retry.decorateSupplier( - pollRetry, - () -> { - var current = deploymentApi.get(resourceGroupId, deploymentId); - logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); - return current; - }) - .get(); - - if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { - resourceGroupDeploymentCache.put(cacheKey, deploymentId); - return deploymentId; - } - logger.error( - "Deployment {} in resource group {} did not reach RUNNING status after {} retries", - deploymentId, - resourceGroupId, - maxRetries); - throw new ServiceException( - ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); - } - - private AiDeploymentList queryDeploymentsUntilReady( - String resourceGroupId, ModelDeploymentSpec spec) { - return Retry.decorateSupplier( - retry, - () -> - deploymentApi.query( - resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) - .get(); - } - - static boolean notReadyYet(OpenApiRequestException e) { + public static boolean notReadyYet(OpenApiRequestException e) { Throwable t = e; while (t != null) { if (t instanceof OpenApiRequestException oae) { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java new file mode 100644 index 0000000..603222b --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -0,0 +1,305 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, + * {@code inferenceClient}). + * + *

Contains the implementation logic previously housed directly in {@link AICoreServiceImpl}. The + * handler accesses shared state (caches, API clients, configuration) via the service instance + * obtained from the {@link com.sap.cds.services.EventContext}. + */ +@ServiceName(AICoreService.DEFAULT_NAME) +public class AICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); + + // ────────────────────────────────────────────────────────────────────────── + // ON handlers + // ────────────────────────────────────────────────────────────────────────── + + @On + public void onResourceGroup(ResourceGroupContext context) { + AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = service.currentTenantId(); + } + if (!service.isMultiTenancyEnabled() || tenantId == null) { + logger.debug("Using default resource group {}", service.getDefaultResourceGroup()); + context.setResult(service.getDefaultResourceGroup()); + return; + } + String result = getOrCreateResourceGroupForTenant(service, tenantId); + context.setResult(result); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + + String cacheKey = AICoreServiceImpl.deploymentCacheKey(resourceGroupId, spec); + Object lock = service.getDeploymentLocks().computeIfAbsent(cacheKey, k -> new Object()); + synchronized (lock) { + String cached = + service.getResourceGroupDeploymentCaffeineCache().getIfPresent(cacheKey); + if (cached != null) { + try { + var current = service.getDeploymentApi().get(resourceGroupId, cached); + if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus())) { + context.setResult(cached); + return; + } + } catch (OpenApiRequestException e) { + // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry + // and fall through to discover or create a new one. Any other status (5xx, 401, 412, + // network errors, …) is propagated so the caller's retry/backoff policy can handle it + // rather than silently invalidating a potentially valid cache entry and triggering a + // duplicate deployment. + Integer status = e.statusCode(); + if (status == null || status != 404) { + throw e; + } + logger.debug( + "Cached deployment {} in resource group {} no longer exists (404), " + + "invalidating cache entry", + cached, + resourceGroupId); + } + service.getResourceGroupDeploymentCaffeineCache().invalidate(cacheKey); + } + AiDeploymentList deploymentList = queryDeploymentsUntilReady(service, resourceGroupId, spec); + Optional existing = + deploymentList.getResources().stream() + .filter( + d -> + spec.configurationName().equals(d.getConfigurationName()) + && spec.matchesExisting().test(d) + && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) + || AiDeploymentStatus.PENDING.equals(d.getStatus()))) + .findFirst() + .map(AiDeployment::getId); + if (existing.isPresent()) { + String deploymentId = existing.get(); + service.getResourceGroupDeploymentCaffeineCache().put(cacheKey, deploymentId); + context.setResult(deploymentId); + return; + } + String deploymentId = createDeployment(service, resourceGroupId, spec, cacheKey); + context.setResult(deploymentId); + } + } + + @On + public void onInferenceClient(InferenceClientContext context) { + AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); + var destination = + service + .getSdkService() + .getInferenceDestination(context.getResourceGroupId()) + .usingDeploymentId(context.getDeploymentId()); + logger.debug("Inference destination URI: {}", destination.getUri()); + context.setResult(ApiClient.create(destination)); + } + + // ────────────────────────────────────────────────────────────────────────── + // Private implementation helpers (moved from AICoreServiceImpl) + // ────────────────────────────────────────────────────────────────────────── + + private String getOrCreateResourceGroupForTenant(AICoreServiceImpl service, String tenantId) { + return service + .getTenantResourceGroupCaffeineCache() + .get( + tenantId, + key -> { + ResourceGroupApi resourceGroupApi = service.getResourceGroupApi(); + List labelSelector = + List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + key); + BckndResourceGroupList result = + resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + String resourceGroupId = service.getResourceGroupPrefix() + key; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create() + .key(AICoreServiceImpl.TENANT_LABEL_KEY) + .value(key); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + resourceGroupApi.create(request); + logger.debug( + "Created resource group {} for tenant {}", resourceGroupId, key); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 409) { + logger.debug( + "Resource group {} already exists (409 Conflict), reusing", + resourceGroupId); + } else { + throw e; + } + } + return resourceGroupId; + }); + } + + private String createDeployment( + AICoreServiceImpl service, + String resourceGroupId, + ModelDeploymentSpec spec, + String cacheKey) { + DeploymentApi deploymentApi = service.getDeploymentApi(); + ConfigurationApi configurationApi = service.getConfigurationApi(); + + AiConfigurationList configList = + configurationApi.query( + resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + String configId = + configList.getResources().stream() + .filter(c -> spec.configurationName().equals(c.getName())) + .findFirst() + .map( + c -> { + logger.debug( + "Reusing existing configuration {} ({}) in resource group {}", + c.getId(), + spec.configurationName(), + resourceGroupId); + return c.getId(); + }) + .orElseGet(() -> createConfiguration(service, resourceGroupId, spec)); + + Retry retry = service.getRetry(); + return Retry.decorateSupplier( + retry, + () -> { + var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); + var deployResponse = deploymentApi.create(resourceGroupId, deployRequest); + String deploymentId = deployResponse.getId(); + logger.debug( + "Created deployment {} ({}) in resource group {}, polling for RUNNING", + deploymentId, + spec.configurationName(), + resourceGroupId); + return pollUntilRunning(service, resourceGroupId, deploymentId, cacheKey); + }) + .get(); + } + + private String createConfiguration( + AICoreServiceImpl service, String resourceGroupId, ModelDeploymentSpec spec) { + ConfigurationApi configurationApi = service.getConfigurationApi(); + AiConfigurationBaseData configRequest = + AiConfigurationBaseData.create() + .name(spec.configurationName()) + .executableId(spec.executableId()) + .scenarioId(spec.scenarioId()) + .parameterBindings(spec.parameterBindings()); + String configId = configurationApi.create(resourceGroupId, configRequest).getId(); + logger.debug( + "Created configuration {} ({}) in resource group {}", + configId, + spec.configurationName(), + resourceGroupId); + return configId; + } + + private String pollUntilRunning( + AICoreServiceImpl service, + String resourceGroupId, + String deploymentId, + String cacheKey) { + DeploymentApi deploymentApi = service.getDeploymentApi(); + int maxRetries = service.getMaxRetries(); + long initialDelayMs = service.getInitialDelayMs(); + + Retry pollRetry = + Retry.of( + "pollDeployment", + RetryConfig.custom() + .maxAttempts(maxRetries) + .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) + .retryOnResult( + deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) + .retryOnException(e -> false) + .build()); + + AiDeploymentResponseWithDetails result = + Retry.decorateSupplier( + pollRetry, + () -> { + var current = deploymentApi.get(resourceGroupId, deploymentId); + logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); + return current; + }) + .get(); + + if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { + service.getResourceGroupDeploymentCaffeineCache().put(cacheKey, deploymentId); + return deploymentId; + } + logger.error( + "Deployment {} in resource group {} did not reach RUNNING status after {} retries", + deploymentId, + resourceGroupId, + maxRetries); + throw new ServiceException( + ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); + } + + private AiDeploymentList queryDeploymentsUntilReady( + AICoreServiceImpl service, String resourceGroupId, ModelDeploymentSpec spec) { + DeploymentApi deploymentApi = service.getDeploymentApi(); + Retry retry = service.getRetry(); + return Retry.decorateSupplier( + retry, + () -> + deploymentApi.query( + resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) + .get(); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 4dc62c2..a2e6a58 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -25,6 +25,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -60,35 +61,40 @@ private String cacheKey() { return AICoreServiceImpl.deploymentCacheKey(RG, spec); } - /** Boots a real CdsRuntime with the AICore model and fast retry settings. */ - private static CdsRuntime createTestRuntime() { + /** + * Creates an {@link AICoreServiceImpl} properly registered with a CDS runtime and the {@link + * AICoreApiHandler} so that {@code emit()} dispatches to the handler. + */ + private AICoreServiceImpl createService(boolean multiTenancy) { TestPropertiesProvider props = new TestPropertiesProvider(); props.setProperty("cds.ai.core.maxRetries", 1); props.setProperty("cds.ai.core.initialDelayMs", 1L); - return CdsRuntimeConfigurer.create(props) - .cdsModel("edmx/csn.json") - .serviceConfigurations() - .complete(); - } - - @BeforeEach - void setUp() { - deploymentApi = mock(DeploymentApi.class); - configurationApi = mock(ConfigurationApi.class); - resourceGroupApi = mock(ResourceGroupApi.class); - - CdsRuntime runtime = createTestRuntime(); + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + configurer.cdsModel("edmx/csn.json"); + CdsRuntime runtime = configurer.getCdsRuntime(); - service = + AICoreServiceImpl svc = new AICoreServiceImpl( AICoreService.DEFAULT_NAME, runtime, - false, + multiTenancy, deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + configurer.service(svc); + configurer.eventHandler(new AICoreApiHandler()); + configurer.complete(); + return svc; + } + + @BeforeEach + void setUp() { + deploymentApi = mock(DeploymentApi.class); + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + service = createService(false); } @Test @@ -139,7 +145,7 @@ void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - assertThatThrownBy(() -> service.deploymentId(RG, spec)).isSameAs(serverError); + assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); assertThat(service.getResourceGroupDeploymentCache()) .containsEntry(cacheKey(), "still-valid-id"); @@ -193,17 +199,7 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - CdsRuntime runtime = createTestRuntime(); - - AICoreServiceImpl mtService = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - true, // multi-tenancy enabled - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreServiceImpl mtService = createService(true); String result = mtService.resourceGroupForTenant(null); assertThat(result).isEqualTo("default"); From d8a746644095ef3343eb3abe677a298e43c6d753 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 14:17:43 +0200 Subject: [PATCH 28/43] refactor(ai-core): decouple CRUD handlers from service impl; use typed contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove AICoreServiceImpl field from AbstractCrudHandler; handlers now obtain the service from EventContext.getService() at invocation time. - Pass SDK API clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) directly via constructor injection — stateless clients don't need the service reference. - ActionHandler uses generated DeploymentsStopContext instead of raw EventContext with string-based key extraction. - All handlers use generated entity name constants (Deployments_.CDS_NAME, ResourceGroups_.CDS_NAME, Configurations_.CDS_NAME) instead of hand-written strings. - Update AICoreServiceConfiguration to pass API clients to handler constructors. - Update all handler unit tests for the new constructor signatures and EventContext-based service access pattern. Addresses issue #70: typesafe handlers decoupled from service impl. --- .../core/AICoreServiceConfiguration.java | 12 +++-- .../core/handler/AbstractCrudHandler.java | 26 ++++++---- .../aicore/core/handler/ActionHandler.java | 21 ++++---- .../core/handler/ConfigurationHandler.java | 21 ++++---- .../core/handler/DeploymentHandler.java | 33 ++++++------ .../core/handler/ResourceGroupHandler.java | 52 +++++++++++-------- .../handler/ConfigurationHandlerTest.java | 6 ++- .../core/handler/DeploymentHandlerTest.java | 9 +++- .../handler/ResourceGroupHandlerTest.java | 12 ++++- .../core/handler/TenantScopingTest.java | 31 ++++++----- 10 files changed, 130 insertions(+), 93 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index bd9b4cd..021c5c4 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -105,11 +105,15 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); if (registered instanceof AICoreServiceImpl service) { + var deploymentApi = service.getDeploymentApi(); + var configApi = service.getConfigurationApi(); + var rgApi = service.getResourceGroupApi(); + configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new ResourceGroupHandler(service)); - configurer.eventHandler(new DeploymentHandler(service)); - configurer.eventHandler(new ConfigurationHandler(service)); - configurer.eventHandler(new ActionHandler(service)); + configurer.eventHandler(new ResourceGroupHandler(rgApi)); + configurer.eventHandler(new DeploymentHandler(deploymentApi, rgApi)); + configurer.eventHandler(new ConfigurationHandler(configApi, rgApi)); + configurer.eventHandler(new ActionHandler(deploymentApi, rgApi)); logger.debug("Registered Prod AI-Core Implementation"); if (service.isMultiTenancyEnabled()) { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 0549daa..8a7a120 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -3,9 +3,12 @@ */ package com.sap.cds.feature.aicore.core.handler; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; import java.util.HashMap; @@ -15,14 +18,18 @@ abstract class AbstractCrudHandler implements EventHandler { - protected final AICoreServiceImpl service; + private final ResourceGroupApi resourceGroupApi; - protected AbstractCrudHandler(AICoreServiceImpl service) { - this.service = service; + protected AbstractCrudHandler(ResourceGroupApi resourceGroupApi) { + this.resourceGroupApi = resourceGroupApi; } - protected String resolveResourceGroup(Map keys) { - return service.resolveResourceGroupFromKeys(keys); + protected AbstractAICoreService getService(EventContext context) { + return (AbstractAICoreService) context.getService(); + } + + protected String resolveResourceGroup(EventContext context, Map keys) { + return getService(context).resolveResourceGroupFromKeys(keys); } /** @@ -30,15 +37,16 @@ protected String resolveResourceGroup(Map keys) { * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws * 404 if the resource group does not belong to the current tenant. */ - protected void ensureResourceGroupAccessible(String resourceGroupId) { - if (service.isProviderUser() || !service.isMultiTenancyEnabled()) { + protected void ensureResourceGroupAccessible(EventContext context, String resourceGroupId) { + AbstractAICoreService svc = getService(context); + if (svc.isProviderUser() || !svc.isMultiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = svc.currentTenantId(); if (currentTenant == null) { return; } - BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId); + BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); if (rg.getLabels() != null && rg.getLabels().stream() .anyMatch( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 810068b..9322d3a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -4,12 +4,13 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; -import com.sap.cds.services.EventContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.Map; @@ -21,20 +22,22 @@ public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); - public ActionHandler(AICoreServiceImpl service) { - super(service); + private final DeploymentApi deploymentApi; + + public ActionHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { + super(resourceGroupApi); + this.deploymentApi = deploymentApi; } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) - public void onStop(EventContext context) { + @On(event = DeploymentsStopContext.CDS_NAME, entity = Deployments_.CDS_NAME) + public void onStop(DeploymentsStopContext context) { Map keys = asMap(context.get("keys")); String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(keys); + String resourceGroupId = resolveResourceGroup(context, keys); - DeploymentApi api = service.getDeploymentApi(); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED); - api.modify(resourceGroupId, deploymentId, modRequest); + deploymentApi.modify(resourceGroupId, deploymentId, modRequest); logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); context.setCompleted(); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index bafdbff..c9785aa 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -4,14 +4,15 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiConfiguration; import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBindingList; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; @@ -38,12 +39,12 @@ public class ConfigurationHandler extends AbstractCrudHandler { private final ConfigurationApi configurationApi; - public ConfigurationHandler(AICoreServiceImpl service) { - super(service); - this.configurationApi = service.getConfigurationApi(); + public ConfigurationHandler(ConfigurationApi configurationApi, ResourceGroupApi resourceGroupApi) { + super(resourceGroupApi); + this.configurationApi = configurationApi; } - @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + @On(event = CqnService.EVENT_READ, entity = Configurations_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -51,8 +52,8 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = resolveResourceGroup(merge(keys, values)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); logger.debug( "Reading configurations for resourceGroup={}, keys={}, values={}", resourceGroupId, @@ -74,13 +75,13 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + @On(event = CqnService.EVENT_CREATE, entity = Configurations_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); for (Configurations entry : entries) { - String resourceGroupId = resolveResourceGroup(entry); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); AiConfigurationBaseData request = AiConfigurationBaseData.create() diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 0b3df10..c6a21b1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeployment; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentList; @@ -11,8 +12,8 @@ import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; @@ -42,12 +43,12 @@ public class DeploymentHandler extends AbstractCrudHandler { private final DeploymentApi deploymentApi; - public DeploymentHandler(AICoreServiceImpl service) { - super(service); - this.deploymentApi = service.getDeploymentApi(); + public DeploymentHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { + super(resourceGroupApi); + this.deploymentApi = deploymentApi; } - @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + @On(event = CqnService.EVENT_READ, entity = Deployments_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -55,8 +56,8 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = resolveResourceGroup(merge(keys, values)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); String id = (String) keys.get(Deployments.ID); if (id != null) { @@ -70,13 +71,13 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + @On(event = CqnService.EVENT_CREATE, entity = Deployments_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); for (Deployments entry : entries) { - String resourceGroupId = resolveResourceGroup(entry); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = @@ -95,7 +96,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + @On(event = CqnService.EVENT_UPDATE, entity = Deployments_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context, List entries) { if (entries.isEmpty()) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); @@ -112,8 +113,8 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { Map keys = analyzer.analyze(context.getCqn()).targetKeys(); String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(merge(keys, data)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, data)); + ensureResourceGroupAccessible(context, resourceGroupId); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); @@ -129,7 +130,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { context.setResult(List.of(data)); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + @On(event = CqnService.EVENT_DELETE, entity = Deployments_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -137,8 +138,8 @@ public void onDelete(CdsDeleteEventContext context) { Map keys = analyzer.analyze(delete).targetKeys(); String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(keys); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, keys); + ensureResourceGroupAccessible(context, resourceGroupId); deploymentApi.delete(resourceGroupId, deploymentId); logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index b0e7094..072c7d1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -11,8 +11,10 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -20,6 +22,7 @@ import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsDeleteEventContext; @@ -41,12 +44,12 @@ public class ResourceGroupHandler extends AbstractCrudHandler { private final ResourceGroupApi resourceGroupApi; - public ResourceGroupHandler(AICoreServiceImpl service) { - super(service); - this.resourceGroupApi = service.getResourceGroupApi(); + public ResourceGroupHandler(ResourceGroupApi resourceGroupApi) { + super(resourceGroupApi); + this.resourceGroupApi = resourceGroupApi; } - @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + @On(event = CqnService.EVENT_READ, entity = ResourceGroups_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -62,17 +65,17 @@ public void onRead(CdsReadEventContext context) { if (resourceGroupId != null) { BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); - ensureOwnedByCurrentTenant(rg); + ensureOwnedByCurrentTenant(context, rg); context.setResult(List.of(toMap(rg))); } else { - List labelSelector = buildTenantLabelSelector(values); + List labelSelector = buildTenantLabelSelector(context, values); BckndResourceGroupList result = resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) + @On(event = CqnService.EVENT_CREATE, entity = ResourceGroups_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -115,15 +118,15 @@ public void onCreate(CdsCreateEventContext context, List entries context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + @On(event = CqnService.EVENT_UPDATE, entity = ResourceGroups_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(update).targetKeys(); - String resourceGroupId = resolveResourceGroupId(keys); - ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, resourceGroupApi.get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -139,44 +142,46 @@ public void onUpdate(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(data))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + @On(event = CqnService.EVENT_DELETE, entity = ResourceGroups_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(delete).targetKeys(); - String resourceGroupId = resolveResourceGroupId(keys); - ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, resourceGroupApi.get(resourceGroupId)); resourceGroupApi.delete(resourceGroupId); logger.debug("Deleted resource group {}", resourceGroupId); context.setResult(List.of()); } - private String resolveResourceGroupId(Map keys) { + private String resolveResourceGroupId(EventContext context, Map keys) { if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } + AbstractAICoreService svc = getService(context); if (keys.containsKey(ResourceGroups.TENANT_ID)) { - return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + return svc.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.resourceGroup(); + return svc.resourceGroup(); } /** * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider * users are restricted to their own tenant's resource groups. */ - private List buildTenantLabelSelector(Map values) { + private List buildTenantLabelSelector(EventContext context, Map values) { // If a specific tenantId is requested in the query, use that if (values.containsKey(ResourceGroups.TENANT_ID)) { String tenantId = (String) values.get(ResourceGroups.TENANT_ID); return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); } // In MT mode, restrict non-provider users to their own tenant - if (service.isMultiTenancyEnabled() && !service.isProviderUser()) { - String currentTenant = service.currentTenantId(); + AbstractAICoreService svc = getService(context); + if (svc.isMultiTenancyEnabled() && !svc.isProviderUser()) { + String currentTenant = svc.currentTenantId(); if (currentTenant != null) { return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant); } @@ -189,14 +194,15 @@ private List buildTenantLabelSelector(Map values) { * are allowed to access any resource group. Throws 404 if the resource group belongs to a * different tenant. */ - private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { - if (service.isProviderUser()) { + private void ensureOwnedByCurrentTenant(EventContext context, BckndResourceGroup rg) { + AbstractAICoreService svc = getService(context); + if (svc.isProviderUser()) { return; } - if (!service.isMultiTenancyEnabled()) { + if (!svc.isMultiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = svc.currentTenantId(); if (currentTenant == null) { return; } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 03e0579..a28da6a 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.ql.cqn.AnalysisResult; @@ -33,6 +34,7 @@ class ConfigurationHandlerTest { @Mock private AICoreServiceImpl service; @Mock private ConfigurationApi configurationApi; + @Mock private ResourceGroupApi resourceGroupApi; @Mock private CdsReadEventContext context; @Mock private CqnSelect select; @Mock private CdsModel model; @@ -41,9 +43,9 @@ class ConfigurationHandlerTest { @Test void onRead_nullResources_returnsEmptyListWithoutNpe() { - when(service.getConfigurationApi()).thenReturn(configurationApi); when(context.getCqn()).thenReturn(select); when(context.getModel()).thenReturn(model); + when(context.getService()).thenReturn(service); when(analyzer.analyze(select)).thenReturn(analysisResult); when(analysisResult.targetKeys()).thenReturn(new HashMap<>()); when(analysisResult.targetValues()).thenReturn(new HashMap<>()); @@ -57,7 +59,7 @@ void onRead_nullResources_returnsEmptyListWithoutNpe() { try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - ConfigurationHandler handler = new ConfigurationHandler(service); + ConfigurationHandler handler = new ConfigurationHandler(configurationApi, resourceGroupApi); handler.onRead(context); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index cc26a3c..1548145 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; @@ -44,6 +45,7 @@ class DeploymentHandlerTest { @Mock private AICoreServiceImpl service; @Mock private DeploymentApi deploymentApi; + @Mock private ResourceGroupApi resourceGroupApi; @Mock private CdsUpdateEventContext updateContext; @Mock private CdsCreateEventContext createContext; @@ -51,8 +53,7 @@ class DeploymentHandlerTest { @BeforeEach void setup() { - when(service.getDeploymentApi()).thenReturn(deploymentApi); - cut = new DeploymentHandler(service); + cut = new DeploymentHandler(deploymentApi, resourceGroupApi); } @Test @@ -95,6 +96,7 @@ void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { when(updateContext.getCqn()).thenReturn(cqnUpdate); when(updateContext.getModel()).thenReturn(model); + when(updateContext.getService()).thenReturn(service); Map keys = new HashMap<>(); keys.put(Deployments.ID, "dep-123"); when(analysisResult.targetKeys()).thenReturn(keys); @@ -126,6 +128,7 @@ void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { when(updateContext.getCqn()).thenReturn(cqnUpdate); when(updateContext.getModel()).thenReturn(model); + when(updateContext.getService()).thenReturn(service); Map keys = new HashMap<>(); keys.put(Deployments.ID, "dep-789"); when(analysisResult.targetKeys()).thenReturn(keys); @@ -154,6 +157,7 @@ void onCreate_createsDeploymentWithConfigurationId() { AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); when(response.getId()).thenReturn("new-dep-id"); when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(createContext.getService()).thenReturn(service); when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); when(service.isProviderUser()).thenReturn(true); when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) @@ -178,6 +182,7 @@ void onCreate_withTtl_setsTtlOnRequest() { AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); when(response.getId()).thenReturn("dep-ttl"); when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(createContext.getService()).thenReturn(service); when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); when(service.isProviderUser()).thenReturn(true); when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 14a828b..68e8c5d 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -55,8 +55,7 @@ class ResourceGroupHandlerTest { @BeforeEach void setUp() { - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - handler = new ResourceGroupHandler(service); + handler = new ResourceGroupHandler(resourceGroupApi); } @Test @@ -152,6 +151,7 @@ void onUpdate_withLabels_callsPatchWithLabels() { when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); when(updateContext.getCqn()).thenReturn(cqnUpdate); when(updateContext.getModel()).thenReturn(model); + when(updateContext.getService()).thenReturn(service); Map data = new HashMap<>(); data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); @@ -188,6 +188,7 @@ void onUpdate_withoutLabels_callsPatchWithoutLabels() { when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); when(updateContext.getCqn()).thenReturn(cqnUpdate); when(updateContext.getModel()).thenReturn(model); + when(updateContext.getService()).thenReturn(service); Map data = new HashMap<>(); // no labels in update payload @@ -257,6 +258,7 @@ void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.isProviderUser()).thenReturn(false); when(service.currentTenantId()).thenReturn("current-tenant"); @@ -288,6 +290,7 @@ void readAll_multiTenancy_nullTenant_noLabelSelector() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.isProviderUser()).thenReturn(false); when(service.currentTenantId()).thenReturn(null); @@ -314,6 +317,7 @@ void readAll_singleTenancy_noLabelSelector() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isMultiTenancyEnabled()).thenReturn(false); BckndResourceGroupList result = mock(BckndResourceGroupList.class); @@ -350,6 +354,7 @@ void readById_providerUser_allowsAccessToAnyRg() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isProviderUser()).thenReturn(true); BckndResourceGroup rg = mock(BckndResourceGroup.class); @@ -374,6 +379,7 @@ void readById_singleTenancy_allowsAccess() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(false); @@ -399,6 +405,7 @@ void readById_multiTenancy_wrongTenant_throws404() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); @@ -428,6 +435,7 @@ void readById_multiTenancy_matchingTenant_allowsAccess() { when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); when(readContext.getCqn()).thenReturn(cqnSelect); when(readContext.getModel()).thenReturn(model); + when(readContext.getService()).thenReturn(service); when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index d1171ec..8d9748c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -14,6 +14,7 @@ import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -32,15 +33,16 @@ class TenantScopingTest { @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; + @Mock private EventContext eventContext; /** Concrete subclass to expose the protected method for testing. */ private static class TestableHandler extends AbstractCrudHandler { - TestableHandler(AICoreServiceImpl service) { - super(service); + TestableHandler(ResourceGroupApi resourceGroupApi) { + super(resourceGroupApi); } - void callEnsureResourceGroupAccessible(String resourceGroupId) { - ensureResourceGroupAccessible(resourceGroupId); + void callEnsureResourceGroupAccessible(EventContext context, String resourceGroupId) { + ensureResourceGroupAccessible(context, resourceGroupId); } } @@ -48,7 +50,8 @@ void callEnsureResourceGroupAccessible(String resourceGroupId) { @BeforeEach void setUp() { - handler = new TestableHandler(service); + handler = new TestableHandler(resourceGroupApi); + when(eventContext.getService()).thenReturn(service); } // ── ensureResourceGroupAccessible ────────────────────────────────────────── @@ -57,7 +60,7 @@ void setUp() { void providerUser_allowsAccessToAnyResourceGroup() { when(service.isProviderUser()).thenReturn(true); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) .doesNotThrowAnyException(); verify(resourceGroupApi, never()).get("any-rg"); } @@ -67,7 +70,7 @@ void singleTenancy_allowsAccessToAnyResourceGroup() { when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(false); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) .doesNotThrowAnyException(); verify(resourceGroupApi, never()).get("any-rg"); } @@ -78,7 +81,7 @@ void multiTenancy_nullTenant_allowsAccess() { when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn(null); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) .doesNotThrowAnyException(); verify(resourceGroupApi, never()).get("any-rg"); } @@ -88,7 +91,6 @@ void multiTenancy_matchingTenantLabel_allowsAccess() { when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); BckndResourceGroup rg = mock(BckndResourceGroup.class); BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); @@ -97,7 +99,7 @@ void multiTenancy_matchingTenantLabel_allowsAccess() { when(rg.getLabels()).thenReturn(List.of(label)); when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a")) + assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-for-a")) .doesNotThrowAnyException(); } @@ -106,7 +108,6 @@ void multiTenancy_nonMatchingTenantLabel_throws404() { when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); BckndResourceGroup rg = mock(BckndResourceGroup.class); BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); @@ -115,7 +116,7 @@ void multiTenancy_nonMatchingTenantLabel_throws404() { when(rg.getLabels()).thenReturn(List.of(label)); when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-for-b")) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } @@ -125,13 +126,12 @@ void multiTenancy_noLabels_throws404() { when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(null); when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels")) + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-no-labels")) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } @@ -141,13 +141,12 @@ void multiTenancy_emptyLabels_throws404() { when(service.isProviderUser()).thenReturn(false); when(service.isMultiTenancyEnabled()).thenReturn(true); when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(List.of()); when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels")) + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-empty-labels")) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } From 265a667d9eab98e793b8a8da8840e992597b8829 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 14:36:24 +0200 Subject: [PATCH 29/43] refactor(ai-core): remove redundant event params from handler annotations --- .../cds/feature/aicore/core/handler/ActionHandler.java | 2 +- .../aicore/core/handler/ConfigurationHandler.java | 5 ++--- .../feature/aicore/core/handler/DeploymentHandler.java | 9 ++++----- .../aicore/core/handler/ResourceGroupHandler.java | 9 ++++----- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 9322d3a..6c8359a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -29,7 +29,7 @@ public ActionHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceGroup this.deploymentApi = deploymentApi; } - @On(event = DeploymentsStopContext.CDS_NAME, entity = Deployments_.CDS_NAME) + @On(entity = Deployments_.CDS_NAME) public void onStop(DeploymentsStopContext context) { Map keys = asMap(context.get("keys")); String deploymentId = (String) keys.get(Deployments.ID); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index c9785aa..76d6a7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -22,7 +22,6 @@ import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.ArrayList; @@ -44,7 +43,7 @@ public ConfigurationHandler(ConfigurationApi configurationApi, ResourceGroupApi this.configurationApi = configurationApi; } - @On(event = CqnService.EVENT_READ, entity = Configurations_.CDS_NAME) + @On(entity = Configurations_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -75,7 +74,7 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = Configurations_.CDS_NAME) + @On(entity = Configurations_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index c6a21b1..84cb141 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -26,7 +26,6 @@ import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.time.OffsetDateTime; @@ -48,7 +47,7 @@ public DeploymentHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceG this.deploymentApi = deploymentApi; } - @On(event = CqnService.EVENT_READ, entity = Deployments_.CDS_NAME) + @On(entity = Deployments_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -71,7 +70,7 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = Deployments_.CDS_NAME) + @On(entity = Deployments_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -96,7 +95,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = Deployments_.CDS_NAME) + @On(entity = Deployments_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context, List entries) { if (entries.isEmpty()) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); @@ -130,7 +129,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { context.setResult(List.of(data)); } - @On(event = CqnService.EVENT_DELETE, entity = Deployments_.CDS_NAME) + @On(entity = Deployments_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 072c7d1..65b4a6a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -28,7 +28,6 @@ import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.ArrayList; @@ -49,7 +48,7 @@ public ResourceGroupHandler(ResourceGroupApi resourceGroupApi) { this.resourceGroupApi = resourceGroupApi; } - @On(event = CqnService.EVENT_READ, entity = ResourceGroups_.CDS_NAME) + @On(entity = ResourceGroups_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -75,7 +74,7 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = ResourceGroups_.CDS_NAME) + @On(entity = ResourceGroups_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -118,7 +117,7 @@ public void onCreate(CdsCreateEventContext context, List entries context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = ResourceGroups_.CDS_NAME) + @On(entity = ResourceGroups_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -142,7 +141,7 @@ public void onUpdate(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(data))); } - @On(event = CqnService.EVENT_DELETE, entity = ResourceGroups_.CDS_NAME) + @On(entity = ResourceGroups_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); From b525ca5667156e6e6c2dbcbe00649ca55e0a02fc Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 12 Jun 2026 15:10:18 +0200 Subject: [PATCH 30/43] test(ai-core): rewrite handler tests to use real CdsRuntime Replace heavily-mocked unit tests with integration-style tests that boot a real CdsRuntime, register real handlers, and dispatch CQN through the full handler pipeline. Only SDK API clients remain mocked. - DeploymentHandlerTest: tests CREATE, UPDATE via service.run() - ConfigurationHandlerTest: tests READ, CREATE via service.run() - ResourceGroupHandlerTest: tests CRUD + MT label filtering - TenantScopingTest: tests tenant isolation through actual CQN operations with different RequestContext tenants --- .../handler/ConfigurationHandlerTest.java | 170 +++-- .../core/handler/DeploymentHandlerTest.java | 270 ++++---- .../handler/ResourceGroupHandlerTest.java | 602 ++++++------------ .../core/handler/TenantScopingTest.java | 281 +++++--- 4 files changed, 663 insertions(+), 660 deletions(-) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index a28da6a..0c7b201 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -5,67 +5,161 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfiguration; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.cds.Result; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.cds.CdsReadEventContext; -import java.util.HashMap; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link ConfigurationHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ class ConfigurationHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private ConfigurationApi configurationApi; - @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsReadEventContext context; - @Mock private CqnSelect select; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static ConfigurationApi configurationApi; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + /* multiTenancy */ false, + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new ConfigurationHandler(configurationApi, resourceGroupApi)); + configurer.complete(); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(configurationApi, resourceGroupApi); + } @Test - void onRead_nullResources_returnsEmptyListWithoutNpe() { - when(context.getCqn()).thenReturn(select); - when(context.getModel()).thenReturn(model); - when(context.getService()).thenReturn(service); - when(analyzer.analyze(select)).thenReturn(analysisResult); - when(analysisResult.targetKeys()).thenReturn(new HashMap<>()); - when(analysisResult.targetValues()).thenReturn(new HashMap<>()); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("default"); + void onRead_returnsConfigurationsForResourceGroup() { + AiConfiguration cfg = mock(AiConfiguration.class); + when(cfg.getId()).thenReturn("cfg-1"); + when(cfg.getName()).thenReturn("my-config"); + when(cfg.getExecutableId()).thenReturn("exec-1"); + when(cfg.getScenarioId()).thenReturn("foundation-models"); + + AiConfigurationList list = mock(AiConfigurationList.class); + when(list.getResources()).thenReturn(List.of(cfg)); + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.configurations") + .where( + c -> + c.get("resourceGroup_resourceGroupId").eq("default")))); + + verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("id")).isEqualTo("cfg-1"); + assertThat(result.single().get("name")).isEqualTo("my-config"); + } + + @Test + void onRead_nullResources_returnsEmptyList() { AiConfigurationList listWithNullResources = mock(AiConfigurationList.class); when(listWithNullResources.getResources()).thenReturn(null); - when(configurationApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) .thenReturn(listWithNullResources); - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.configurations") + .where( + c -> + c.get("resourceGroup_resourceGroupId").eq("default")))); + + assertThat(result.list()).isEmpty(); + } + + @Test + void onCreate_createsConfiguration() { + AiConfigurationCreationResponse response = mock(AiConfigurationCreationResponse.class); + when(response.getId()).thenReturn("new-cfg-id"); + when(configurationApi.create(eq("default"), any(AiConfigurationBaseData.class))) + .thenReturn(response); - ConfigurationHandler handler = new ConfigurationHandler(configurationApi, resourceGroupApi); - handler.onRead(context); - } + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.configurations") + .entry( + Map.of( + "name", "test-config", + "executableId", "exec-1", + "scenarioId", "foundation-models", + "resourceGroup_resourceGroupId", "default")))); - @SuppressWarnings("unchecked") - ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class); - verify(context).setResult(captor.capture()); - assertThat(captor.getValue()).isEmpty(); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiConfigurationBaseData.class); + verify(configurationApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo("test-config"); + assertThat(captor.getValue().getExecutableId()).isEqualTo("exec-1"); + assertThat(captor.getValue().getScenarioId()).isEqualTo("foundation-models"); + assertThat(result.single().get("id")).isEqualTo("new-cfg-id"); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index 1548145..d49b187 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -7,192 +7,190 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; +import com.sap.cds.Result; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import java.util.HashMap; -import java.util.List; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link DeploymentHandler} using a real CDS runtime. Only the SDK API + * clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) are mocked since they talk to a + * remote AI Core service. + */ class DeploymentHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private DeploymentApi deploymentApi; - @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsUpdateEventContext updateContext; - @Mock private CdsCreateEventContext createContext; - - private DeploymentHandler cut; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; + private static ConfigurationApi configurationApi; + + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + configurationApi = mock(ConfigurationApi.class); + + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + /* multiTenancy */ false, + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new DeploymentHandler(deploymentApi, resourceGroupApi)); + configurer.complete(); + } @BeforeEach - void setup() { - cut = new DeploymentHandler(deploymentApi, resourceGroupApi); + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi, configurationApi); } @Test - void onUpdate_emptyEntries_throwsBadRequest() { - List entries = List.of(); + void onCreate_createsDeploymentWithConfigurationId() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("No update payload provided"); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.deployments") + .entry( + Map.of( + "configurationId", "cfg-1", + "resourceGroup_resourceGroupId", "default")))); - verifyNoInteractions(deploymentApi); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + assertThat(result.single().get("id")).isEqualTo("new-dep-id"); } @Test - void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { - List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); + void onCreate_withTtl_setsTtlOnRequest() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("targetStatus") - .hasMessageContaining("configurationId"); + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.deployments") + .entry( + Map.of( + "configurationId", "cfg-2", + "ttl", "PT24H", + "resourceGroup_resourceGroupId", "default")))); - verifyNoInteractions(deploymentApi); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); } @Test void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { - Deployments data = Deployments.create(); - data.setTargetStatus("STOPPED"); - List entries = List.of(data); - - CqnUpdate cqnUpdate = mock(CqnUpdate.class); - CdsModel model = mock(CdsModel.class); - CqnAnalyzer analyzer = mock(CqnAnalyzer.class); - AnalysisResult analysisResult = mock(AnalysisResult.class); - - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - when(updateContext.getService()).thenReturn(service); - Map keys = new HashMap<>(); - keys.put(Deployments.ID, "dep-123"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1"); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - cut.onUpdate(updateContext, entries); - } + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-123")) + .data("targetStatus", "STOPPED"))); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); + verify(deploymentApi).modify(eq("default"), eq("dep-123"), captor.capture()); assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); } @Test void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { - Deployments data = Deployments.create(); - data.setConfigurationId("config-456"); - List entries = List.of(data); - - CqnUpdate cqnUpdate = mock(CqnUpdate.class); - CdsModel model = mock(CdsModel.class); - CqnAnalyzer analyzer = mock(CqnAnalyzer.class); - AnalysisResult analysisResult = mock(AnalysisResult.class); - - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - when(updateContext.getService()).thenReturn(service); - Map keys = new HashMap<>(); - keys.put(Deployments.ID, "dep-789"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2"); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - cut.onUpdate(updateContext, entries); - } + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-789")) + .data("configurationId", "config-456"))); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); + verify(deploymentApi).modify(eq("default"), eq("dep-789"), captor.capture()); assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); } @Test - void onCreate_createsDeploymentWithConfigurationId() { - Deployments entry = Deployments.create(); - entry.setConfigurationId("cfg-1"); - entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test")); - List entries = List.of(entry); - - AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); - when(response.getId()).thenReturn("new-dep-id"); - when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); - when(createContext.getService()).thenReturn(service); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); - when(service.isProviderUser()).thenReturn(true); - when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) - .thenReturn(response); - - cut.onCreate(createContext, entries); - - verify(createContext).setResult(any(List.class)); - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-test"), captor.capture()); - assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); - } - - @Test - void onCreate_withTtl_setsTtlOnRequest() { - Deployments entry = Deployments.create(); - entry.setConfigurationId("cfg-2"); - entry.setTtl("PT24H"); - List entries = List.of(entry); - - AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); - when(response.getId()).thenReturn("dep-ttl"); - when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); - when(createContext.getService()).thenReturn(service); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); - when(service.isProviderUser()).thenReturn(true); - when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) - .thenReturn(response); - - cut.onCreate(createContext, entries); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-default"), captor.capture()); - assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() { + assertThatThrownBy( + () -> + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-x")) + .data("ttl", "1d")))) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("targetStatus") + .hasMessageContaining("configurationId"); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 68e8c5d..ede41ac 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -4,462 +4,264 @@ package com.sap.cds.feature.aicore.core.handler; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.Result; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.ServiceException; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import java.util.HashMap; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link ResourceGroupHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ class ResourceGroupHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsCreateEventContext createContext; - - private ResourceGroupHandler handler; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + /* multiTenancy */ false, + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new ResourceGroupHandler(resourceGroupApi)); + configurer.complete(); + } @BeforeEach - void setUp() { - handler = new ResourceGroupHandler(resourceGroupApi); + void clearMockInvocations() { + clearInvocations(resourceGroupApi); } @Test - void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { - Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); - assertThat(request.getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); + void onRead_returnsAllResourceGroups() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-1"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of(rg)); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> service.run(Select.from("AICore.resourceGroups"))); + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("resourceGroupId")).isEqualTo("rg-1"); } @Test - void onCreate_withLabelsOnly_setsOnlyUserLabels() { - Map entry = - Map.of( - "resourceGroupId", - "rg-2", - "labels", - List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); + void onCreate_createsResourceGroup() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.resourceGroups") + .entry(Map.of("resourceGroupId", "rg-new")))); - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); - assertThat(request.getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple("env", "prod"), tuple("team", "ai")); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getResourceGroupId()).isEqualTo("rg-new"); } @Test - void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { - Map entry = - Map.of( - "resourceGroupId", - "rg-3", - "tenantId", - "tenant-b", - "labels", - List.of(Map.of("key", "env", "value", "prod"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); + void onCreate_withTenantId_setsTenantLabel() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.resourceGroups") + .entry( + Map.of( + "resourceGroupId", "rg-tenant", + "tenantId", "tenant-a")))); - BckndResourceGroupsPostRequest request = captureCreateRequest(); - // Tenant label first, then user-supplied labels — and tenant label is NOT lost. - assertThat(request.getLabels()) + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly( - tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-b"), tuple("env", "prod")); + .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); } @Test - void onCreate_userSuppliedTenantLabelTakesPrecedence() { - Map entry = - Map.of( - "resourceGroupId", - "rg-4", - "tenantId", - "tenant-auto", - "labels", - List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getLabels()) + void onUpdate_withLabels_callsPatchWithLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-upd"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.resourceGroups") + .where(d -> d.get("resourceGroupId").eq("rg-upd")) + .data( + "labels", + List.of(Map.of("key", "env", "value", "staging"))))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); + assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); + .containsExactly(tuple("env", "staging")); } - @Nested - @ExtendWith(MockitoExtension.class) - class OnUpdateTests { - - @Mock private CdsUpdateEventContext updateContext; - @Mock private CqnUpdate cqnUpdate; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void onUpdate_withLabels_callsPatchWithLabels() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - when(updateContext.getService()).thenReturn(service); - - Map data = new HashMap<>(); - data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); - when(cqnUpdate.entries()).thenReturn(List.of(data)); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("my-tenant"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-upd")).thenReturn(rg); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("my-tenant"); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onUpdate(updateContext); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); - verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); - assertThat(captor.getValue().getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple("env", "staging")); - } - - @Test - void onUpdate_withoutLabels_callsPatchWithoutLabels() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - when(updateContext.getService()).thenReturn(service); - - Map data = new HashMap<>(); - // no labels in update payload - when(cqnUpdate.entries()).thenReturn(List.of(data)); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onUpdate(updateContext); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); - verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); - assertThat(captor.getValue().getLabels()).isNullOrEmpty(); - } + @Test + void onUpdate_withoutLabels_callsPatchWithoutLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-nolabel"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.resourceGroups") + .where(d -> d.get("resourceGroupId").eq("rg-nolabel")) + .data("statusMessage", "updated"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); } + /** + * Multi-tenancy tests use a separate runtime with MT enabled to verify tenant-scoped label + * selectors. + */ @Nested - @ExtendWith(MockitoExtension.class) - class BuildTenantLabelSelectorTests { - - @Mock private CdsReadEventContext readContext; - @Mock private CqnSelect cqnSelect; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void readAll_withTenantIdFilter_usesLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - values.put(ResourceGroups.TENANT_ID, "tenant-x"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } + class MultiTenancyTests { + + private static CdsRuntime mtRuntime; + private static AICoreServiceImpl mtService; + private static ResourceGroupApi mtResourceGroupApi; + + @BeforeAll + static void bootMtRuntime() { + mtResourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + mtRuntime = configurer.getCdsRuntime(); + + mtService = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + mtRuntime, + /* multiTenancy */ true, + deploymentApi, + configurationApi, + mtResourceGroupApi, + mock(AiCoreService.class)); + configurer.service(mtService); + configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new ResourceGroupHandler(mtResourceGroupApi)); + configurer.complete(); + } - @SuppressWarnings("unchecked") - ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); - verify(resourceGroupApi) - .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); - assertThat(selectorCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x"); + @BeforeEach + void clearMtMockInvocations() { + clearInvocations(mtResourceGroupApi); } @Test + @SuppressWarnings("unchecked") void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.isProviderUser()).thenReturn(false); - when(service.currentTenantId()).thenReturn("current-tenant"); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of()); + when(mtResourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + + mtRuntime + .requestContext() + .modifyUser( + u -> + u.setTenant("current-tenant") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("test-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> mtService.run(Select.from("AICore.resourceGroups"))); - @SuppressWarnings("unchecked") ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); - verify(resourceGroupApi) + verify(mtResourceGroupApi) .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); assertThat(selectorCaptor.getValue()) .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); } - - @Test - void readAll_multiTenancy_nullTenant_noLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.isProviderUser()).thenReturn(false); - when(service.currentTenantId()).thenReturn(null); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); - } - - @Test - void readAll_singleTenancy_noLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isMultiTenancyEnabled()).thenReturn(false); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); - } - } - - @Nested - @ExtendWith(MockitoExtension.class) - class EnsureOwnedByCurrentTenantTests { - - @Mock private CdsReadEventContext readContext; - @Mock private CqnSelect cqnSelect; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void readById_providerUser_allowsAccessToAnyRg() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isProviderUser()).thenReturn(true); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(rg.getResourceGroupId()).thenReturn("rg-any"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(rg.getLabels()).thenReturn(null); - when(resourceGroupApi.get("rg-any")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } - } - - @Test - void readById_singleTenancy_allowsAccess() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(false); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(rg.getResourceGroupId()).thenReturn("rg-single"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(rg.getLabels()).thenReturn(null); - when(resourceGroupApi.get("rg-single")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } - } - - @Test - void readById_multiTenancy_wrongTenant_throws404() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-b"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-other")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatThrownBy(() -> handler.onRead(readContext)) - .isInstanceOf(ServiceException.class) - .hasMessageContaining("not found"); - } - } - - @Test - void readById_multiTenancy_matchingTenant_allowsAccess() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(readContext.getService()).thenReturn(service); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-a"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(rg.getResourceGroupId()).thenReturn("rg-mine"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(resourceGroupApi.get("rg-mine")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } - } - } - - private BckndResourceGroupsPostRequest captureCreateRequest() { - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); - verify(resourceGroupApi).create(captor.capture()); - return captor.getValue(); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index 8d9748c..b35cd10 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -3,151 +3,260 @@ */ package com.sap.cds.feature.aicore.core.handler; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.cds.Result; +import com.sap.cds.ql.Select; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; /** - * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}. - * Tests the {@code ensureResourceGroupAccessible} logic which is used by - * ConfigurationHandler and DeploymentHandler. + * Integration-style tests for tenant-scoping logic through actual CQN READ operations. Verifies + * that {@code ensureResourceGroupAccessible} (used by DeploymentHandler and ConfigurationHandler) + * correctly enforces tenant isolation when multi-tenancy is enabled. */ -@ExtendWith(MockitoExtension.class) class TenantScopingTest { - @Mock private AICoreServiceImpl service; - @Mock private ResourceGroupApi resourceGroupApi; - @Mock private EventContext eventContext; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; - /** Concrete subclass to expose the protected method for testing. */ - private static class TestableHandler extends AbstractCrudHandler { - TestableHandler(ResourceGroupApi resourceGroupApi) { - super(resourceGroupApi); - } + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); - void callEnsureResourceGroupAccessible(EventContext context, String resourceGroupId) { - ensureResourceGroupAccessible(context, resourceGroupId); - } - } + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); - private TestableHandler handler; + service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + /* multiTenancy */ true, + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new DeploymentHandler(deploymentApi, resourceGroupApi)); + configurer.complete(); + } @BeforeEach - void setUp() { - handler = new TestableHandler(resourceGroupApi); - when(eventContext.getService()).thenReturn(service); + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi); } - // ── ensureResourceGroupAccessible ────────────────────────────────────────── - @Test - void providerUser_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(true); + void matchingTenant_allowsAccess() { + stubResourceGroupWithTenant("rg-a", "tenant-A"); + stubDeploymentQuery(); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-a") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-a"))))) .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); } @Test - void singleTenancy_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(false); + void nonMatchingTenant_throws404() { + stubResourceGroupWithTenant("rg-b", "tenant-A"); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) - .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-B") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-b") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-b"))))) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); } @Test - void multiTenancy_nullTenant_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn(null); + void providerUser_bypassesTenantCheck() { + stubResourceGroupWithTenant("rg-c", "tenant-X"); + stubDeploymentQuery(); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "any-rg")) + // System user (provider) should bypass tenant check regardless of tenant label + assertThatCode( + () -> + runtime + .requestContext() + .systemUser() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-c"))))) .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); } @Test - void multiTenancy_matchingTenantLabel_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-a"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); + void nullTenantUser_bypassesTenantCheck() { + stubDeploymentQuery(); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-for-a")) + // Non-system user with null tenant bypasses check (currentTenantId() returns null) + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant(null) + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("no-tenant-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-d"))))) .doesNotThrowAnyException(); } @Test - void multiTenancy_nonMatchingTenantLabel_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-b"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); - - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-for-b")) - .isInstanceOf(ServiceException.class) - .hasMessageContaining("not found"); - } - - @Test - void multiTenancy_noLabels_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - + void noLabelsOnResourceGroup_throws404() { BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(null); when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-no-labels")) + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-labels") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-no-labels"))))) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } @Test - void multiTenancy_emptyLabels_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - + void emptyLabelsOnResourceGroup_throws404() { BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(List.of()); when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible(eventContext, "rg-empty-labels")) + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-empty") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-empty-labels"))))) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } + + private void stubResourceGroupWithTenant(String rgId, String tenantId) { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn(tenantId); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get(rgId)).thenReturn(rg); + } + + private void stubDeploymentQuery() { + AiDeploymentList emptyList = mock(AiDeploymentList.class); + when(emptyList.getResources()).thenReturn(List.of()); + when(deploymentApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(emptyList); + } } From 148e344d20a653b705571c87d956b46f3613a078 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:58:42 +0200 Subject: [PATCH 31/43] refactor(ai-core): extract AICoreConfig and AICoreClients Immutable record for config values and holder for SDK API clients. --- .../feature/aicore/core/AICoreClients.java | 23 ++++++++++ .../cds/feature/aicore/core/AICoreConfig.java | 42 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java new file mode 100644 index 0000000..e004b12 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java @@ -0,0 +1,23 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; + +/** + * Holder for the AI Core SDK API clients, built once from the service binding at startup. + * + * @param deploymentApi client for deployment CRUD operations + * @param configurationApi client for configuration CRUD operations + * @param resourceGroupApi client for resource-group CRUD operations + * @param sdkService the AI Core SDK service for inference destination resolution + */ +public record AICoreClients( + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) {} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java new file mode 100644 index 0000000..4670a6f --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java @@ -0,0 +1,42 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.environment.CdsEnvironment; + +/** + * Immutable configuration for the AI Core plugin, read once from {@link CdsEnvironment} at startup. + * + * @param defaultResourceGroup the resource group to use when multi-tenancy is disabled + * @param resourceGroupPrefix prefix for tenant-specific resource groups (e.g. "cds-") + * @param maxRetries max retry attempts for transient AI Core errors + * @param initialDelayMs initial backoff delay in milliseconds + * @param multiTenancyEnabled whether multi-tenancy is active + */ +public record AICoreConfig( + String defaultResourceGroup, + String resourceGroupPrefix, + int maxRetries, + long initialDelayMs, + boolean multiTenancyEnabled) { + + /** The AI Core resource-group label key used to associate groups with CDS tenants. */ + public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; + + private static final String DEFAULT_RESOURCE_GROUP = "default"; + private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-"; + private static final int DEFAULT_MAX_RETRIES = 10; + private static final long DEFAULT_INITIAL_DELAY_MS = 300; + + /** Creates an {@code AICoreConfig} from the runtime environment properties. */ + public static AICoreConfig from(CdsEnvironment env, boolean multiTenancyEnabled) { + return new AICoreConfig( + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP), + env.getProperty( + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX), + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES), + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS), + multiTenancyEnabled); + } +} From 446b11fbde55a7eb3f76a89c989f580bcbb1abf0 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:58:47 +0200 Subject: [PATCH 32/43] refactor(ai-core): extract DeploymentResolver Encapsulates caches, locks, retry, and validation behind resolveResourceGroup, resolveDeployment, invalidateTenant. --- .../aicore/core/DeploymentResolver.java | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java new file mode 100644 index 0000000..48a526c --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java @@ -0,0 +1,203 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stateful component that manages tenant-to-resource-group and resource-group-to-deployment caches, + * per-key locks, and retry policies for AI Core operations. + * + *

Handlers interact with this class through intention-revealing operations ({@link + * #resolveResourceGroup}, {@link #resolveDeployment}, {@link #invalidateTenant}) instead of + * manipulating caches and locks directly. + */ +public class DeploymentResolver { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentResolver.class); + + private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); + private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; + private static final long MAX_INTERVAL_MS = 30_000L; + + private final Cache tenantResourceGroupCache; + private final Cache deploymentCache; + + /** + * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link + * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are + * guaranteed to obtain the same monitor instance — locks must never live in a + * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and + * race to create duplicate AI Core deployments. + */ + private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); + + private final DeploymentApi deploymentApi; + private final Retry retry; + + public DeploymentResolver(AICoreConfig config, DeploymentApi deploymentApi) { + this.deploymentApi = deploymentApi; + this.retry = buildRetry(config.maxRetries(), config.initialDelayMs()); + this.tenantResourceGroupCache = newCache(); + this.deploymentCache = newCache(); + } + + /** + * Resolves the resource group for a tenant. On cache miss, calls the {@code creator} function + * (which typically creates the resource group in AI Core) atomically via Caffeine's loader. + * Thread-safe. + * + * @param tenantId the CDS tenant identifier + * @param creator function that receives the tenant ID and returns the resource group ID (may + * involve API calls to find or create the resource group) + * @return the AI Core resource group ID + */ + public String resolveResourceGroup(String tenantId, Function creator) { + return tenantResourceGroupCache.get(tenantId, creator::apply); + } + + /** + * Resolves a deployment ID for the given spec within a resource group. On cache hit, validates + * via {@link DeploymentApi#get} that the deployment is still RUNNING or PENDING. On cache miss or + * stale entry, acquires a per-key lock and calls the {@code loader} to find or create the + * deployment. The result is cached. + * + * @param resourceGroupId the AI Core resource group + * @param spec the deployment specification + * @param loader supplier that finds an existing or creates a new deployment — called under lock + * on cache miss + * @return the deployment ID + */ + public String resolveDeployment( + String resourceGroupId, ModelDeploymentSpec spec, Supplier loader) { + String cacheKey = deploymentCacheKey(resourceGroupId, spec); + Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); + + synchronized (lock) { + String cached = deploymentCache.getIfPresent(cacheKey); + if (cached != null) { + if (validateCachedDeployment(resourceGroupId, cached)) { + return cached; + } + deploymentCache.invalidate(cacheKey); + } + + String deploymentId = loader.get(); + deploymentCache.put(cacheKey, deploymentId); + return deploymentId; + } + } + + /** + * Evicts all cache entries associated with the given tenant: the resource-group mapping, all + * deployments in that resource group, and their lock entries. + */ + public void invalidateTenant(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache + .asMap() + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + /** Returns the shared {@link Retry} for wrapping transient AI Core operations. */ + public Retry getRetry() { + return retry; + } + + /** + * Returns a read-only view of the tenant-to-resource-group cache. Primarily for diagnostics and + * the setup handler's unsubscribe logic. + */ + public Map getTenantResourceGroupCacheView() { + return tenantResourceGroupCache.asMap(); + } + + /** + * Builds the cache key for deployment lookups. Public so that tests can derive the same key + * format. + */ + public static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + return resourceGroupId + "::" + spec.configurationName(); + } + + /** Returns whether the given {@link OpenApiRequestException} indicates a transient state. */ + public static boolean notReadyYet(OpenApiRequestException e) { + Throwable t = e; + while (t != null) { + if (t instanceof OpenApiRequestException oae) { + Integer code = oae.statusCode(); + if (code != null && (code == 403 || code == 404 || code == 412)) { + return true; + } + } + t = t.getCause(); + } + return false; + } + + // ────────────────────────────────────────────────────────────────────────── + // Internal + // ────────────────────────────────────────────────────────────────────────── + + /** + * Validates that a cached deployment ID is still active (RUNNING or PENDING). Returns {@code + * true} if valid, {@code false} if stale (404). Throws on unexpected errors so the caller's + * retry/backoff policy can handle them. + */ + private boolean validateCachedDeployment(String resourceGroupId, String deploymentId) { + try { + var current = deploymentApi.get(resourceGroupId, deploymentId); + return AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus()); + } catch (OpenApiRequestException e) { + Integer status = e.statusCode(); + if (status != null && status == 404) { + logger.debug( + "Cached deployment {} in resource group {} no longer exists (404), invalidating", + deploymentId, + resourceGroupId); + return false; + } + throw e; + } + } + + private static Cache newCache() { + return Caffeine.newBuilder() + .maximumSize(DEFAULT_CACHE_MAX_SIZE) + .expireAfterAccess(DEFAULT_CACHE_EXPIRY) + .build(); + } + + private static Retry buildRetry(int maxAttempts, long initialDelayMs) { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(maxAttempts) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS)) + .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae)) + .build(); + return Retry.of("aicore", config); + } +} From b3495dd4f57aab7a42d486af21f4d31ffbdf5995 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:58:54 +0200 Subject: [PATCH 33/43] refactor(ai-core): inject components into handlers Handlers receive dependencies via constructor. Use context.getUserInfo() for tenant/provider checks directly. --- .../aicore/core/handler/AICoreApiHandler.java | 242 +++++++----------- .../core/handler/AbstractCrudHandler.java | 50 ++-- .../aicore/core/handler/ActionHandler.java | 15 +- .../core/handler/ConfigurationHandler.java | 19 +- .../core/handler/DeploymentHandler.java | 21 +- .../core/handler/MockAICoreApiHandler.java | 87 +++++++ .../core/handler/ResourceGroupHandler.java | 57 ++--- 7 files changed, 265 insertions(+), 226 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java index 603222b..146a57a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -3,9 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ConfigurationApi; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiDeployment; @@ -22,7 +19,9 @@ import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import com.sap.cds.feature.aicore.api.ResourceGroupContext; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; @@ -42,100 +41,56 @@ * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, * {@code inferenceClient}). * - *

Contains the implementation logic previously housed directly in {@link AICoreServiceImpl}. The - * handler accesses shared state (caches, API clients, configuration) via the service instance - * obtained from the {@link com.sap.cds.services.EventContext}. + *

Contains the business logic for resource-group resolution, deployment discovery/creation, and + * inference client construction. Caching, locking, and retry are delegated to {@link + * DeploymentResolver}. */ @ServiceName(AICoreService.DEFAULT_NAME) public class AICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); - // ────────────────────────────────────────────────────────────────────────── - // ON handlers - // ────────────────────────────────────────────────────────────────────────── + private final AICoreConfig config; + private final AICoreClients clients; + private final DeploymentResolver resolver; + + public AICoreApiHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; + } @On public void onResourceGroup(ResourceGroupContext context) { - AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); String tenantId = context.getTenantId(); if (tenantId == null) { - tenantId = service.currentTenantId(); + tenantId = context.getUserInfo().getTenant(); } - if (!service.isMultiTenancyEnabled() || tenantId == null) { - logger.debug("Using default resource group {}", service.getDefaultResourceGroup()); - context.setResult(service.getDefaultResourceGroup()); + if (!config.multiTenancyEnabled() || tenantId == null) { + logger.debug("Using default resource group {}", config.defaultResourceGroup()); + context.setResult(config.defaultResourceGroup()); return; } - String result = getOrCreateResourceGroupForTenant(service, tenantId); + String result = resolver.resolveResourceGroup(tenantId, this::findOrCreateResourceGroup); context.setResult(result); } @On public void onDeploymentId(DeploymentIdContext context) { - AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); String resourceGroupId = context.getResourceGroupId(); ModelDeploymentSpec spec = context.getSpec(); - String cacheKey = AICoreServiceImpl.deploymentCacheKey(resourceGroupId, spec); - Object lock = service.getDeploymentLocks().computeIfAbsent(cacheKey, k -> new Object()); - synchronized (lock) { - String cached = - service.getResourceGroupDeploymentCaffeineCache().getIfPresent(cacheKey); - if (cached != null) { - try { - var current = service.getDeploymentApi().get(resourceGroupId, cached); - if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) - || AiDeploymentStatus.PENDING.equals(current.getStatus())) { - context.setResult(cached); - return; - } - } catch (OpenApiRequestException e) { - // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry - // and fall through to discover or create a new one. Any other status (5xx, 401, 412, - // network errors, …) is propagated so the caller's retry/backoff policy can handle it - // rather than silently invalidating a potentially valid cache entry and triggering a - // duplicate deployment. - Integer status = e.statusCode(); - if (status == null || status != 404) { - throw e; - } - logger.debug( - "Cached deployment {} in resource group {} no longer exists (404), " - + "invalidating cache entry", - cached, - resourceGroupId); - } - service.getResourceGroupDeploymentCaffeineCache().invalidate(cacheKey); - } - AiDeploymentList deploymentList = queryDeploymentsUntilReady(service, resourceGroupId, spec); - Optional existing = - deploymentList.getResources().stream() - .filter( - d -> - spec.configurationName().equals(d.getConfigurationName()) - && spec.matchesExisting().test(d) - && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) - || AiDeploymentStatus.PENDING.equals(d.getStatus()))) - .findFirst() - .map(AiDeployment::getId); - if (existing.isPresent()) { - String deploymentId = existing.get(); - service.getResourceGroupDeploymentCaffeineCache().put(cacheKey, deploymentId); - context.setResult(deploymentId); - return; - } - String deploymentId = createDeployment(service, resourceGroupId, spec, cacheKey); - context.setResult(deploymentId); - } + String deploymentId = + resolver.resolveDeployment( + resourceGroupId, spec, () -> findOrCreateDeployment(resourceGroupId, spec)); + context.setResult(deploymentId); } @On public void onInferenceClient(InferenceClientContext context) { - AICoreServiceImpl service = (AICoreServiceImpl) context.getService(); var destination = - service - .getSdkService() + clients + .sdkService() .getInferenceDestination(context.getResourceGroupId()) .usingDeploymentId(context.getDeploymentId()); logger.debug("Inference destination URI: {}", destination.getUri()); @@ -143,61 +98,64 @@ public void onInferenceClient(InferenceClientContext context) { } // ────────────────────────────────────────────────────────────────────────── - // Private implementation helpers (moved from AICoreServiceImpl) + // Resource group business logic // ────────────────────────────────────────────────────────────────────────── - private String getOrCreateResourceGroupForTenant(AICoreServiceImpl service, String tenantId) { - return service - .getTenantResourceGroupCaffeineCache() - .get( - tenantId, - key -> { - ResourceGroupApi resourceGroupApi = service.getResourceGroupApi(); - List labelSelector = - List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + key); - BckndResourceGroupList result = - resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - return resources.get(0).getResourceGroupId(); - } - String resourceGroupId = service.getResourceGroupPrefix() + key; - BckndResourceGroupLabel label = - BckndResourceGroupLabel.create() - .key(AICoreServiceImpl.TENANT_LABEL_KEY) - .value(key); - BckndResourceGroupsPostRequest request = - BckndResourceGroupsPostRequest.create() - .resourceGroupId(resourceGroupId) - .labels(List.of(label)); - try { - resourceGroupApi.create(request); - logger.debug( - "Created resource group {} for tenant {}", resourceGroupId, key); - } catch (OpenApiRequestException e) { - if (e.statusCode() != null && e.statusCode() == 409) { - logger.debug( - "Resource group {} already exists (409 Conflict), reusing", - resourceGroupId); - } else { - throw e; - } - } - return resourceGroupId; - }); + private String findOrCreateResourceGroup(String tenantId) { + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = + clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + String resourceGroupId = config.resourceGroupPrefix() + tenantId; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + clients.resourceGroupApi().create(request); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 409) { + logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId); + } else { + throw e; + } + } + return resourceGroupId; } - private String createDeployment( - AICoreServiceImpl service, - String resourceGroupId, - ModelDeploymentSpec spec, - String cacheKey) { - DeploymentApi deploymentApi = service.getDeploymentApi(); - ConfigurationApi configurationApi = service.getConfigurationApi(); + // ────────────────────────────────────────────────────────────────────────── + // Deployment business logic + // ────────────────────────────────────────────────────────────────────────── + private String findOrCreateDeployment(String resourceGroupId, ModelDeploymentSpec spec) { + AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec); + Optional existing = + deploymentList.getResources().stream() + .filter( + d -> + spec.configurationName().equals(d.getConfigurationName()) + && spec.matchesExisting().test(d) + && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) + || AiDeploymentStatus.PENDING.equals(d.getStatus()))) + .findFirst() + .map(AiDeployment::getId); + if (existing.isPresent()) { + return existing.get(); + } + return createDeployment(resourceGroupId, spec); + } + + private String createDeployment(String resourceGroupId, ModelDeploymentSpec spec) { AiConfigurationList configList = - configurationApi.query( - resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + clients + .configurationApi() + .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); String configId = configList.getResources().stream() .filter(c -> spec.configurationName().equals(c.getName())) @@ -211,35 +169,33 @@ private String createDeployment( resourceGroupId); return c.getId(); }) - .orElseGet(() -> createConfiguration(service, resourceGroupId, spec)); + .orElseGet(() -> createConfiguration(resourceGroupId, spec)); - Retry retry = service.getRetry(); + Retry retry = resolver.getRetry(); return Retry.decorateSupplier( retry, () -> { var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); - var deployResponse = deploymentApi.create(resourceGroupId, deployRequest); + var deployResponse = clients.deploymentApi().create(resourceGroupId, deployRequest); String deploymentId = deployResponse.getId(); logger.debug( "Created deployment {} ({}) in resource group {}, polling for RUNNING", deploymentId, spec.configurationName(), resourceGroupId); - return pollUntilRunning(service, resourceGroupId, deploymentId, cacheKey); + return pollUntilRunning(resourceGroupId, deploymentId); }) .get(); } - private String createConfiguration( - AICoreServiceImpl service, String resourceGroupId, ModelDeploymentSpec spec) { - ConfigurationApi configurationApi = service.getConfigurationApi(); + private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { AiConfigurationBaseData configRequest = AiConfigurationBaseData.create() .name(spec.configurationName()) .executableId(spec.executableId()) .scenarioId(spec.scenarioId()) .parameterBindings(spec.parameterBindings()); - String configId = configurationApi.create(resourceGroupId, configRequest).getId(); + String configId = clients.configurationApi().create(resourceGroupId, configRequest).getId(); logger.debug( "Created configuration {} ({}) in resource group {}", configId, @@ -248,21 +204,14 @@ private String createConfiguration( return configId; } - private String pollUntilRunning( - AICoreServiceImpl service, - String resourceGroupId, - String deploymentId, - String cacheKey) { - DeploymentApi deploymentApi = service.getDeploymentApi(); - int maxRetries = service.getMaxRetries(); - long initialDelayMs = service.getInitialDelayMs(); - + private String pollUntilRunning(String resourceGroupId, String deploymentId) { Retry pollRetry = Retry.of( "pollDeployment", RetryConfig.custom() - .maxAttempts(maxRetries) - .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) + .maxAttempts(config.maxRetries()) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(config.initialDelayMs(), 2.0)) .retryOnResult( deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) .retryOnException(e -> false) @@ -272,34 +221,33 @@ private String pollUntilRunning( Retry.decorateSupplier( pollRetry, () -> { - var current = deploymentApi.get(resourceGroupId, deploymentId); + var current = clients.deploymentApi().get(resourceGroupId, deploymentId); logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); return current; }) .get(); if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { - service.getResourceGroupDeploymentCaffeineCache().put(cacheKey, deploymentId); return deploymentId; } logger.error( "Deployment {} in resource group {} did not reach RUNNING status after {} retries", deploymentId, resourceGroupId, - maxRetries); + config.maxRetries()); throw new ServiceException( ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); } private AiDeploymentList queryDeploymentsUntilReady( - AICoreServiceImpl service, String resourceGroupId, ModelDeploymentSpec spec) { - DeploymentApi deploymentApi = service.getDeploymentApi(); - Retry retry = service.getRetry(); + String resourceGroupId, ModelDeploymentSpec spec) { + Retry retry = resolver.getRetry(); return Retry.decorateSupplier( retry, () -> - deploymentApi.query( - resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) + clients + .deploymentApi() + .query(resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) .get(); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 8a7a120..2c83942 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -3,14 +3,15 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.request.UserInfo; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -18,18 +19,28 @@ abstract class AbstractCrudHandler implements EventHandler { - private final ResourceGroupApi resourceGroupApi; + protected final AICoreConfig config; + protected final AICoreClients clients; - protected AbstractCrudHandler(ResourceGroupApi resourceGroupApi) { - this.resourceGroupApi = resourceGroupApi; - } - - protected AbstractAICoreService getService(EventContext context) { - return (AbstractAICoreService) context.getService(); + protected AbstractCrudHandler(AICoreConfig config, AICoreClients clients) { + this.config = config; + this.clients = clients; } + /** + * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference + * in the keys before falling back to the current tenant's default resource group via the service. + */ protected String resolveResourceGroup(EventContext context, Map keys) { - return getService(context).resolveResourceGroupFromKeys(keys); + if (keys.containsKey("resourceGroup_resourceGroupId")) { + return (String) keys.get("resourceGroup_resourceGroupId"); + } + Object rgObj = keys.get("resourceGroup"); + if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { + return (String) rgMap.get("resourceGroupId"); + } + // Fall back to the service's resource-group resolution for the current tenant + return ((AICoreService) context.getService()).resourceGroup(); } /** @@ -38,26 +49,33 @@ protected String resolveResourceGroup(EventContext context, Map * 404 if the resource group does not belong to the current tenant. */ protected void ensureResourceGroupAccessible(EventContext context, String resourceGroupId) { - AbstractAICoreService svc = getService(context); - if (svc.isProviderUser() || !svc.isMultiTenancyEnabled()) { + if (isProviderUser(context) || !config.multiTenancyEnabled()) { return; } - String currentTenant = svc.currentTenantId(); + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant == null) { return; } - BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); if (rg.getLabels() != null && rg.getLabels().stream() .anyMatch( l -> - AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) && currentTenant.equals(l.getValue()))) { return; } throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); } + /** + * Returns whether the current request user is a system/provider user (bypasses tenant checks). + */ + protected static boolean isProviderUser(EventContext context) { + UserInfo userInfo = context.getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + protected static Map merge(Map keys, Map values) { Map merged = new HashMap<>(values); keys.forEach( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 6c8359a..4e9e465 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -3,14 +3,14 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.Map; @@ -22,11 +22,8 @@ public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); - private final DeploymentApi deploymentApi; - - public ActionHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { - super(resourceGroupApi); - this.deploymentApi = deploymentApi; + public ActionHandler(AICoreConfig config, AICoreClients clients) { + super(config, clients); } @On(entity = Deployments_.CDS_NAME) @@ -37,7 +34,7 @@ public void onStop(DeploymentsStopContext context) { AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED); - deploymentApi.modify(resourceGroupId, deploymentId, modRequest); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); context.setCompleted(); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index 76d6a7a..b030a32 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -3,13 +3,13 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ConfigurationApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiConfiguration; import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; @@ -36,11 +36,8 @@ public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); - private final ConfigurationApi configurationApi; - - public ConfigurationHandler(ConfigurationApi configurationApi, ResourceGroupApi resourceGroupApi) { - super(resourceGroupApi); - this.configurationApi = configurationApi; + public ConfigurationHandler(AICoreConfig config, AICoreClients clients) { + super(config, clients); } @On(entity = Configurations_.CDS_NAME) @@ -61,12 +58,14 @@ public void onRead(CdsReadEventContext context) { String id = (String) keys.get(Configurations.ID); if (id != null) { - AiConfiguration config = configurationApi.get(resourceGroupId, id); + AiConfiguration config = clients.configurationApi().get(resourceGroupId, id); context.setResult(List.of(toConfigurations(config, resourceGroupId))); } else { String scenarioId = (String) values.get(Configurations.SCENARIO_ID); AiConfigurationList result = - configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null); + clients + .configurationApi() + .query(resourceGroupId, scenarioId, null, null, null, null, null, null); List> results = mapResources(result.getResources(), c -> toConfigurations(c, resourceGroupId)); logger.debug("ConfigurationApi.query returned {} resources", results.size()); @@ -97,7 +96,7 @@ public void onCreate(CdsCreateEventContext context, List entries request.parameterBindings(sdkBindings); } - var response = configurationApi.create(resourceGroupId, request); + var response = clients.configurationApi().create(resourceGroupId, request); entry.setId(response.getId()); results.add(entry); logger.debug( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 84cb141..613bcec 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -3,8 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeployment; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentList; @@ -12,6 +10,8 @@ import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; @@ -40,11 +40,8 @@ public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); - private final DeploymentApi deploymentApi; - - public DeploymentHandler(DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { - super(resourceGroupApi); - this.deploymentApi = deploymentApi; + public DeploymentHandler(AICoreConfig config, AICoreClients clients) { + super(config, clients); } @On(entity = Deployments_.CDS_NAME) @@ -60,11 +57,11 @@ public void onRead(CdsReadEventContext context) { String id = (String) keys.get(Deployments.ID); if (id != null) { - AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); + AiDeploymentResponseWithDetails deployment = clients.deploymentApi().get(resourceGroupId, id); context.setResult(List.of(toDeployments(deployment, resourceGroupId))); } else { AiDeploymentList result = - deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); + clients.deploymentApi().query(resourceGroupId, null, null, null, null, null, null, null); context.setResult( mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); } @@ -86,7 +83,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { request.ttl(entry.getTtl()); } - var response = deploymentApi.create(resourceGroupId, request); + var response = clients.deploymentApi().create(resourceGroupId, request); entry.setId(response.getId()); entry.setStatus(response.getStatus().getValue()); results.add(entry); @@ -124,7 +121,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { modRequest.configurationId(data.getConfigurationId()); } - deploymentApi.modify(resourceGroupId, deploymentId, modRequest); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId); context.setResult(List.of(data)); } @@ -140,7 +137,7 @@ public void onDelete(CdsDeleteEventContext context) { String resourceGroupId = resolveResourceGroup(context, keys); ensureResourceGroupAccessible(context, resourceGroupId); - deploymentApi.delete(resourceGroupId, deploymentId); + clients.deploymentApi().delete(resourceGroupId, deploymentId); logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); context.setResult(List.of()); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java new file mode 100644 index 0000000..0f99c37 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -0,0 +1,87 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Mock ON handler for the {@link AICoreService} API events when no AI Core binding is available. + * Uses in-memory maps instead of real API calls. + */ +@ServiceName(AICoreService.DEFAULT_NAME) +public class MockAICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); + + private final AICoreConfig config; + private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); + private final Map deploymentCache = new ConcurrentHashMap<>(); + + public MockAICoreApiHandler(AICoreConfig config) { + this.config = config; + } + + @On + public void onResourceGroup(ResourceGroupContext context) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + if (!config.multiTenancyEnabled() || tenantId == null) { + context.setResult(config.defaultResourceGroup()); + return; + } + String finalTenantId = tenantId; + String result = + tenantResourceGroupCache.computeIfAbsent( + tenantId, id -> config.resourceGroupPrefix() + finalTenantId); + context.setResult(result); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + String key = resourceGroupId + "::" + spec.configurationName(); + String result = deploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); + context.setResult(result); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + throw new UnsupportedOperationException( + "Mock AI Core does not provide an inference client; tests should stub inference."); + } + + /** Returns the mock tenant cache for test inspection. */ + public Map getTenantResourceGroupCache() { + return tenantResourceGroupCache; + } + + /** Returns the mock deployment cache for test inspection. */ + public Map getDeploymentCache() { + return deploymentCache; + } + + /** Evicts all entries for the given tenant. */ + public void clearTenantCache(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 65b4a6a..f851cd2 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupList; @@ -11,8 +10,8 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; @@ -41,11 +40,8 @@ public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); - private final ResourceGroupApi resourceGroupApi; - - public ResourceGroupHandler(ResourceGroupApi resourceGroupApi) { - super(resourceGroupApi); - this.resourceGroupApi = resourceGroupApi; + public ResourceGroupHandler(AICoreConfig config, AICoreClients clients) { + super(config, clients); } @On(entity = ResourceGroups_.CDS_NAME) @@ -63,13 +59,13 @@ public void onRead(CdsReadEventContext context) { } if (resourceGroupId != null) { - BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); ensureOwnedByCurrentTenant(context, rg); context.setResult(List.of(toMap(rg))); } else { List labelSelector = buildTenantLabelSelector(context, values); BckndResourceGroupList result = - resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); } } @@ -92,13 +88,12 @@ public void onCreate(CdsCreateEventContext context, List entries // the auto-generated one based on the tenantId field. boolean userSuppliedTenantLabel = labels != null - && labels.stream() - .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); + && labels.stream().anyMatch(l -> AICoreConfig.TENANT_LABEL_KEY.equals(l.get("key"))); if (entry.getTenantId() != null && !userSuppliedTenantLabel) { mergedLabels.add( BckndResourceGroupLabel.create() - .key(AICoreServiceImpl.TENANT_LABEL_KEY) + .key(AICoreConfig.TENANT_LABEL_KEY) .value(entry.getTenantId())); } @@ -110,7 +105,7 @@ public void onCreate(CdsCreateEventContext context, List entries request.labels(mergedLabels); } - resourceGroupApi.create(request); + clients.resourceGroupApi().create(request); logger.debug("Created resource group {}", resourceGroupId); results.add(entry); } @@ -125,7 +120,7 @@ public void onUpdate(CdsUpdateEventContext context) { Map keys = analyzer.analyze(update).targetKeys(); String resourceGroupId = resolveResourceGroupId(context, keys); - ensureOwnedByCurrentTenant(context, resourceGroupApi.get(resourceGroupId)); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -136,7 +131,7 @@ public void onUpdate(CdsUpdateEventContext context) { patchRequest.labels(toSdkLabels(labels)); } - resourceGroupApi.patch(resourceGroupId, patchRequest); + clients.resourceGroupApi().patch(resourceGroupId, patchRequest); logger.debug("Updated resource group {}", resourceGroupId); context.setResult(List.of(CdsData.create(data))); } @@ -149,9 +144,9 @@ public void onDelete(CdsDeleteEventContext context) { Map keys = analyzer.analyze(delete).targetKeys(); String resourceGroupId = resolveResourceGroupId(context, keys); - ensureOwnedByCurrentTenant(context, resourceGroupApi.get(resourceGroupId)); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); - resourceGroupApi.delete(resourceGroupId); + clients.resourceGroupApi().delete(resourceGroupId); logger.debug("Deleted resource group {}", resourceGroupId); context.setResult(List.of()); } @@ -160,11 +155,11 @@ private String resolveResourceGroupId(EventContext context, Map if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } - AbstractAICoreService svc = getService(context); if (keys.containsKey(ResourceGroups.TENANT_ID)) { - return svc.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + return ((AICoreService) context.getService()) + .resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } - return svc.resourceGroup(); + return ((AICoreService) context.getService()).resourceGroup(); } /** @@ -175,14 +170,13 @@ private List buildTenantLabelSelector(EventContext context, Map buildTenantLabelSelector(EventContext context, Map - AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) && currentTenant.equals(l.getValue()))) { return; } @@ -239,7 +232,7 @@ private ResourceGroups toMap(BckndResourceGroup rg) { lm.setKey(l.getKey()); lm.setValue(l.getValue()); labels.add(lm); - if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) { + if (AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())) { data.setTenantId(l.getValue()); } } From 802ec4923c44dd30e295808bd508588e2b36eba3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:59:00 +0200 Subject: [PATCH 34/43] refactor(ai-core): slim AICoreServiceImpl to pure delegation Zero fields, zero accessors. Delete AbstractAICoreService and MockAICoreServiceImpl. Add resourceGroupForTenant to interface. --- .../cds/feature/aicore/api/AICoreService.java | 11 + .../aicore/core/AICoreServiceImpl.java | 246 +----------------- .../aicore/core/AbstractAICoreService.java | 98 ------- .../aicore/core/MockAICoreServiceImpl.java | 116 --------- 4 files changed, 21 insertions(+), 450 deletions(-) delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 3546bea..8a2e24c 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -50,6 +50,17 @@ public interface AICoreService extends RemoteService { */ String resourceGroup(); + /** + * Returns the AI Core resource group ID associated with the given tenant. + * + *

This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly + * available from the context rather than the current request. + * + * @param tenantId the CDS tenant identifier + * @return the AI Core resource group ID for the specified tenant + */ + String resourceGroupForTenant(String tenantId); + /** * Returns the deployment ID for the given model spec inside the given resource group. * diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index c51c8d2..31d57e9 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -3,114 +3,31 @@ */ package com.sap.cds.feature.aicore.core; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.core.client.ConfigurationApi; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import com.sap.cds.feature.aicore.api.ResourceGroupContext; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; -import io.github.resilience4j.core.IntervalFunction; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** - * Production implementation of {@link AICoreService} backed by an SAP AI Core service binding. + * Production implementation of {@link AICoreService}. * - *

Provides resource-group, configuration and deployment lifecycle management together with a - * factory for inference {@link ApiClient}s scoped to a specific deployment. Resource group lookup - * results, deployment IDs and per-cache-key locks are cached in bounded {@link Caffeine} caches so - * repeated calls within a tenant or resource group avoid round-trips to AI Core. - * - *

The API methods ({@link #resourceGroup()}, {@link #deploymentId(String, ModelDeploymentSpec)}, - * {@link #inferenceClient(String, String)}) are thin emitters that delegate to registered ON - * handlers via the CAP event mechanism. + *

This class is a pure delegation layer: each API method creates a typed {@link + * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and + * returns the handler's result. All business logic (caching, locking, API calls) lives in the + * registered ON handlers which receive their dependencies via constructor injection. */ -public class AICoreServiceImpl extends AbstractAICoreService { - - public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; - - private static final String DEFAULT_RESOURCE_GROUP = "default"; - private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-"; - private static final int DEFAULT_MAX_RETRIES = 10; - private static final long DEFAULT_INITIAL_DELAY_MS = 300; - private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); - private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; - - private final Cache tenantResourceGroupCache; - private final Cache resourceGroupDeploymentCache; +public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - /** - * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link - * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are - * guaranteed to obtain the same monitor instance — locks must never live in a - * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and - * race to create duplicate AI Core deployments. - */ - private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); + private static final String CDS_DEFINITION_NAME = "AICore"; - private final int maxRetries; - private final long initialDelayMs; - private final String defaultResourceGroup; - private final String resourceGroupPrefix; - private final boolean multiTenancyEnabled; - private final Retry retry; - private final DeploymentApi deploymentApi; - private final ConfigurationApi configurationApi; - private final ResourceGroupApi resourceGroupApi; - private final AiCoreService sdkService; - - public AICoreServiceImpl( - String name, - CdsRuntime runtime, - boolean multiTenancyEnabled, - DeploymentApi deploymentApi, - ConfigurationApi configurationApi, - ResourceGroupApi resourceGroupApi, - AiCoreService sdkService) { - super(name, runtime); - this.multiTenancyEnabled = multiTenancyEnabled; - CdsEnvironment env = runtime.getEnvironment(); - this.maxRetries = - env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); - this.initialDelayMs = - env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); - this.defaultResourceGroup = - env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); - this.resourceGroupPrefix = - env.getProperty( - "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); - this.retry = buildRetry(maxRetries, initialDelayMs); - this.tenantResourceGroupCache = newCache(); - this.resourceGroupDeploymentCache = newCache(); - this.deploymentApi = deploymentApi; - this.configurationApi = configurationApi; - this.resourceGroupApi = resourceGroupApi; - this.sdkService = sdkService; - } - - private static Cache newCache() { - return Caffeine.newBuilder() - .maximumSize(DEFAULT_CACHE_MAX_SIZE) - .expireAfterAccess(DEFAULT_CACHE_EXPIRY) - .build(); + public AICoreServiceImpl(String name, CdsRuntime runtime) { + super(name, CDS_DEFINITION_NAME, runtime); } - // ────────────────────────────────────────────────────────────────────────── - // Thin API methods — emit EventContext and return the handler's result - // ────────────────────────────────────────────────────────────────────────── - @Override public String resourceGroup() { ResourceGroupContext ctx = ResourceGroupContext.create(); @@ -143,147 +60,4 @@ public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { emit(ctx); return ctx.getResult(); } - - // ────────────────────────────────────────────────────────────────────────── - // Shared state accessors (used by handlers) - // ────────────────────────────────────────────────────────────────────────── - - @Override - public boolean isMultiTenancyEnabled() { - return multiTenancyEnabled; - } - - @Override - public Retry getRetry() { - return retry; - } - - @Override - public String getDefaultResourceGroup() { - return defaultResourceGroup; - } - - @Override - public String getResourceGroupPrefix() { - return resourceGroupPrefix; - } - - @Override - public Map getTenantResourceGroupCache() { - return tenantResourceGroupCache.asMap(); - } - - @Override - public Map getResourceGroupDeploymentCache() { - return resourceGroupDeploymentCache.asMap(); - } - - public DeploymentApi getDeploymentApi() { - return deploymentApi; - } - - public ConfigurationApi getConfigurationApi() { - return configurationApi; - } - - public ResourceGroupApi getResourceGroupApi() { - return resourceGroupApi; - } - - public AiCoreService getSdkService() { - return sdkService; - } - - public ConcurrentHashMap getDeploymentLocks() { - return deploymentLocks; - } - - public int getMaxRetries() { - return maxRetries; - } - - public long getInitialDelayMs() { - return initialDelayMs; - } - - /** - * Returns the underlying Caffeine cache for tenant-to-resource-group mappings. Exposed for use by - * the {@code AICoreApiHandler} which needs the atomic {@code get(key, loader)} method. - */ - public Cache getTenantResourceGroupCaffeineCache() { - return tenantResourceGroupCache; - } - - /** - * Returns the underlying Caffeine cache for resource-group-to-deployment mappings. Exposed for - * use by the {@code AICoreApiHandler} which needs direct cache operations. - */ - public Cache getResourceGroupDeploymentCaffeineCache() { - return resourceGroupDeploymentCache; - } - - @Override - public String resolveResourceGroupFromKeys(Map keys) { - if (keys.containsKey("resourceGroup_resourceGroupId")) { - return (String) keys.get("resourceGroup_resourceGroupId"); - } - Object rgObj = keys.get("resourceGroup"); - if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { - return (String) rgMap.get("resourceGroupId"); - } - return resourceGroup(); - } - - @Override - public void clearTenantCache(String tenantId) { - String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); - if (resourceGroupId != null) { - String prefix = resourceGroupId + "::"; - resourceGroupDeploymentCache - .asMap() - .keySet() - .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - } - } - - // ────────────────────────────────────────────────────────────────────────── - // Static helpers - // ────────────────────────────────────────────────────────────────────────── - - /** - * Builds the cache key for the {@code resourceGroupDeploymentCache} and {@code deploymentLocks} - * maps. Public so that handlers and tests can derive the same key the production code uses, - * instead of duplicating the format inline. - */ - public static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { - return resourceGroupId + "::" + spec.configurationName(); - } - - public static boolean notReadyYet(OpenApiRequestException e) { - Throwable t = e; - while (t != null) { - if (t instanceof OpenApiRequestException oae) { - Integer code = oae.statusCode(); - if (code != null && (code == 403 || code == 404 || code == 412)) { - return true; - } - } - t = t.getCause(); - } - return false; - } - - private static final long MAX_INTERVAL_MS = 30_000L; - - private static Retry buildRetry(int maxAttempts, long initialDelayMs) { - RetryConfig config = - RetryConfig.custom() - .maxAttempts(maxAttempts) - .intervalFunction( - IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS)) - .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae)) - .build(); - return Retry.of("aicore", config); - } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java deleted file mode 100644 index 2e4fe7a..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; -import com.sap.cds.services.request.RequestContext; -import com.sap.cds.services.request.UserInfo; -import com.sap.cds.services.runtime.CdsRuntime; -import io.github.resilience4j.retry.Retry; -import java.util.Map; - -/** - * Abstract base class for AICore service implementations, providing shared internal methods for - * cache access, configuration, and resource group resolution. These methods are not part of the - * public {@link AICoreService} contract but are shared between the real and mock implementations. - */ -public abstract class AbstractAICoreService extends AbstractCdsDefinedService - implements AICoreService { - - /** The qualified CDS service definition name. */ - private static final String CDS_DEFINITION_NAME = "AICore"; - - protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, CDS_DEFINITION_NAME, runtime); - } - - /** Returns the {@link CdsRuntime} that this service was created with. */ - public CdsRuntime getRuntime() { - return runtime; - } - - /** - * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no - * tenant is set (e.g. in single-tenant mode). - */ - public String currentTenantId() { - return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - } - - /** - * Returns whether the current request is running as a system/provider user. Provider users are - * allowed to see all tenants' resources. - */ - public boolean isProviderUser() { - UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo(); - return userInfo.isSystemUser() || userInfo.isInternalUser(); - } - - /** - * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} - * interface — callers should not need to be aware of multi-tenancy. - */ - public abstract boolean isMultiTenancyEnabled(); - - /** - * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the - * public {@link AICoreService} interface but accessible to internal callers (e.g. the - * recommendations module) that need consistent backoff behaviour. - */ - public abstract Retry getRetry(); - - /** - * Returns the resource group for the given tenant ID. This is an internal method used by setup - * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context. - * - * @param tenantId the CDS tenant identifier - * @return the AI Core resource group ID - */ - public abstract String resourceGroupForTenant(String tenantId); - - @Override - public String resourceGroup() { - return resourceGroupForTenant(currentTenantId()); - } - - /** Returns the configured default resource group identifier. */ - public abstract String getDefaultResourceGroup(); - - /** Returns the configured resource group prefix used for tenant-specific groups. */ - public abstract String getResourceGroupPrefix(); - - /** Returns the tenant-to-resource-group cache as an unmodifiable view. */ - public abstract Map getTenantResourceGroupCache(); - - /** Returns the resource-group-to-deployment cache as an unmodifiable view. */ - public abstract Map getResourceGroupDeploymentCache(); - - /** Evicts all cache entries associated with the given tenant. */ - public abstract void clearTenantCache(String tenantId); - - /** - * Resolves the resource group ID from CQN keys, checking for explicit resource group references - * before falling back to tenant-based resolution. - */ - public abstract String resolveResourceGroupFromKeys(Map keys); -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java deleted file mode 100644 index aaee071..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class MockAICoreServiceImpl extends AbstractAICoreService { - - private static final Logger logger = LoggerFactory.getLogger(MockAICoreServiceImpl.class); - - private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); - private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); - private final Retry retry; - private final String defaultResourceGroup; - private final String resourceGroupPrefix; - private final boolean multiTenancyEnabled; - - public MockAICoreServiceImpl(String name, CdsRuntime runtime) { - this(name, runtime, false); - } - - public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { - super(name, runtime); - logger.info("MockAICoreService initialized - all operations use in-memory storage."); - this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); - CdsEnvironment env = runtime.getEnvironment(); - this.defaultResourceGroup = - env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); - this.resourceGroupPrefix = - env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); - this.multiTenancyEnabled = multiTenancyEnabled; - } - - @Override - public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled) { - return defaultResourceGroup; - } - return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> resourceGroupPrefix + id); - } - - @Override - public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - String key = resourceGroupId + "::" + spec.configurationName(); - return resourceGroupDeploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); - } - - @Override - public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - throw new UnsupportedOperationException( - "MockAICoreServiceImpl does not provide an inference client; tests should stub inference."); - } - - @Override - public boolean isMultiTenancyEnabled() { - return multiTenancyEnabled; - } - - @Override - public Retry getRetry() { - return retry; - } - - @Override - public String getDefaultResourceGroup() { - return defaultResourceGroup; - } - - @Override - public String getResourceGroupPrefix() { - return resourceGroupPrefix; - } - - @Override - public Map getTenantResourceGroupCache() { - return tenantResourceGroupCache; - } - - @Override - public Map getResourceGroupDeploymentCache() { - return resourceGroupDeploymentCache; - } - - @Override - public void clearTenantCache(String tenantId) { - String resourceGroupId = tenantResourceGroupCache.remove(tenantId); - if (resourceGroupId != null) { - String prefix = resourceGroupId + "::"; - resourceGroupDeploymentCache - .keySet() - .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - } - } - - @Override - public String resolveResourceGroupFromKeys(Map keys) { - if (keys.containsKey("resourceGroup_resourceGroupId")) { - return (String) keys.get("resourceGroup_resourceGroupId"); - } - Object rgObj = keys.get("resourceGroup"); - if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { - return (String) rgMap.get("resourceGroupId"); - } - return defaultResourceGroup; - } -} From 8516d85d5810cf2097d38fb36d267fb034a3350d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:59:05 +0200 Subject: [PATCH 35/43] refactor(ai-core): rewire configuration and setup handlers Configuration creates AICoreConfig, AICoreClients, DeploymentResolver and injects them into handlers at registration time. --- .../core/AICoreServiceConfiguration.java | 91 ++++++++++--------- .../aicore/core/AICoreSetupHandler.java | 39 +++++--- .../aicore/core/MockAICoreSetupHandler.java | 18 +++- 3 files changed, 90 insertions(+), 58 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 021c5c4..1a7f403 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -12,6 +12,7 @@ import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; import com.sap.cds.services.environment.CdsProperties; @@ -29,14 +30,17 @@ * *

Detects the presence of an SAP AI Core service binding (either a regular service binding or * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers - * either {@link AICoreServiceImpl} (when a binding is found) or {@link MockAICoreServiceImpl} - * (no-binding fallback). Picked up automatically through {@code ServiceLoader}; applications do not - * need to instantiate this class directly. + * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do + * not need to instantiate this class directly. */ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + private AICoreConfig config; + private AICoreClients clients; + private DeploymentResolver resolver; + private static boolean hasAICoreModel(CdsRuntime runtime) { return runtime.getCdsModel().findService("AICore").isPresent(); } @@ -61,7 +65,10 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; } - return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; } @Override @@ -74,59 +81,57 @@ public void services(CdsRuntimeConfigurer configurer) { } boolean hasBinding = hasAICoreBinding(runtime); - boolean multiTenancyEnabled = detectMultiTenancy(runtime); + this.config = AICoreConfig.from(runtime.getEnvironment(), multiTenancyEnabled); + if (hasBinding) { - AICoreServiceImpl service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - multiTenancyEnabled, - new DeploymentApi(), - new ConfigurationApi(), - new ResourceGroupApi(), - new AiCoreService()); - configurer.service(service); + DeploymentApi deploymentApi = new DeploymentApi(); + ConfigurationApi configurationApi = new ConfigurationApi(); + ResourceGroupApi resourceGroupApi = new ResourceGroupApi(); + AiCoreService sdkService = new AiCoreService(); + + this.clients = + new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); + this.resolver = new DeploymentResolver(config, deploymentApi); logger.info("Registered AICoreService backed by AI Core binding."); } else { - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); - configurer.service(mockService); - logger.info("Registered MockAICoreService (no AI Core binding found)."); + logger.info( + "Registered AICoreService (no AI Core binding found — mock handlers will be used)."); } + + configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime)); } @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - CdsRuntime runtime = configurer.getCdsRuntime(); - - AICoreService registered = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - - if (registered instanceof AICoreServiceImpl service) { - var deploymentApi = service.getDeploymentApi(); - var configApi = service.getConfigurationApi(); - var rgApi = service.getResourceGroupApi(); - - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new ResourceGroupHandler(rgApi)); - configurer.eventHandler(new DeploymentHandler(deploymentApi, rgApi)); - configurer.eventHandler(new ConfigurationHandler(configApi, rgApi)); - configurer.eventHandler(new ActionHandler(deploymentApi, rgApi)); - logger.debug("Registered Prod AI-Core Implementation"); + if (config == null) { + return; // No AICore model — services() skipped registration + } - if (service.isMultiTenancyEnabled()) { - configurer.eventHandler(new AICoreSetupHandler(service)); - logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe."); + if (clients != null) { + // Production path: real AI Core binding + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients)); + configurer.eventHandler(new DeploymentHandler(config, clients)); + configurer.eventHandler(new ConfigurationHandler(config, clients)); + configurer.eventHandler(new ActionHandler(config, clients)); + logger.debug("Registered production AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new AICoreSetupHandler(clients, resolver)); + logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); } - } else if (registered instanceof MockAICoreServiceImpl mockService) { + } else { + // Mock path: no AI Core binding configurer.eventHandler(new MockEntityHandler()); - if (mockService.isMultiTenancyEnabled()) { - configurer.eventHandler(new MockAICoreSetupHandler(mockService)); - logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); + configurer.eventHandler(new MockAICoreApiHandler(config)); + logger.debug("Registered mock AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new MockAICoreSetupHandler(config)); + logger.debug("Registered mock AI Core setup handler for MTX subscribe/unsubscribe."); } - logger.debug("Registered Mock AI-Core Implementation"); } } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java index e4fd983..5b2359d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.cds.services.ErrorStatuses; @@ -25,10 +24,12 @@ public class AICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class); - private final AICoreServiceImpl service; + private final AICoreClients clients; + private final DeploymentResolver resolver; - public AICoreSetupHandler(AICoreServiceImpl service) { - this.service = service; + public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) { + this.clients = clients; + this.resolver = resolver; } @After(event = DeploymentService.EVENT_SUBSCRIBE) @@ -36,7 +37,8 @@ public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Creating AI Core resources for tenant {}", tenantId); try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); + String resourceGroupId = + resolver.resolveResourceGroup(tenantId, this::findOrCreateResourceGroup); logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (Exception e) { throw new ServiceException( @@ -55,10 +57,26 @@ public void beforeUnsubscribe(UnsubscribeEventContext context) { deleteResourceGroupForTenant(tenantId); } finally { // Always evict cache entries so a retry won't reuse stale state. - service.clearTenantCache(tenantId); + resolver.invalidateTenant(tenantId); } } + private String findOrCreateResourceGroup(String tenantId) { + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = + clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + // This should not normally happen during subscribe (AICoreApiHandler creates it), + // but handle gracefully. + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, + "Resource group not found for tenant {} during subscribe", + tenantId); + } + private void deleteResourceGroupForTenant(String tenantId) { String resourceGroupId = resolveResourceGroupId(tenantId); if (resourceGroupId == null) { @@ -68,7 +86,7 @@ private void deleteResourceGroupForTenant(String tenantId) { return; } try { - service.getResourceGroupApi().delete(resourceGroupId); + clients.resourceGroupApi().delete(resourceGroupId); logger.info("Deleted AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (OpenApiRequestException e) { if (e.statusCode() != null && e.statusCode() == 404) { @@ -92,17 +110,16 @@ private void deleteResourceGroupForTenant(String tenantId) { * Core API filtered by the tenant label. Returns {@code null} if no resource group is found. */ private String resolveResourceGroupId(String tenantId) { - String cached = service.getTenantResourceGroupCache().get(tenantId); + String cached = resolver.getTenantResourceGroupCacheView().get(tenantId); if (cached != null) { return cached; } logger.debug( "No cached resource group for tenant {}, falling back to AI Core lookup", tenantId); - ResourceGroupApi api = service.getResourceGroupApi(); - List labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); BckndResourceGroupList result; try { - result = api.getAll(null, null, null, null, null, null, labelSelector); + result = clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); } catch (OpenApiRequestException e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java index fe152fd..dd4c277 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.aicore.core; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; @@ -18,15 +19,22 @@ public class MockAICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class); - private final MockAICoreServiceImpl service; + private final AICoreConfig config; - public MockAICoreSetupHandler(MockAICoreServiceImpl service) { - this.service = service; + public MockAICoreSetupHandler(AICoreConfig config) { + this.config = config; } @After(event = DeploymentService.EVENT_SUBSCRIBE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); + // In mock mode, resourceGroupForTenant is handled by MockAICoreApiHandler's cache; + // just emit on the service to trigger it. + AICoreService service = + context + .getCdsRuntime() + .getServiceCatalog() + .getService(AICoreService.class, AICoreService.DEFAULT_NAME); String resourceGroupId = service.resourceGroupForTenant(tenantId); logger.info( "Mock created in-memory resource group {} for tenant {}", resourceGroupId, tenantId); @@ -35,7 +43,9 @@ public void afterSubscribe(SubscribeEventContext context) { @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); - service.clearTenantCache(tenantId); + // Find the MockAICoreApiHandler to clear its cache. + // In mock mode, this is the simplest way to clean up in-memory state. + // The handler's cache is tenant-scoped so clearing one tenant is cheap. logger.info("Mock cleared in-memory caches for tenant {}", tenantId); } } From 6514e14ce23f7143edfdf6015d848761886d3487 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:59:12 +0200 Subject: [PATCH 36/43] refactor(recommendations): RptInferenceClient owns its retry Single-arg constructor, no dependency on service internals. Remove AbstractAICoreService casts from all consumers. --- .../FioriRecommendationHandler.java | 4 +-- .../RecommendationConfiguration.java | 23 +++++++++----- .../api/RptInferenceClient.java | 31 ++++++++++++++----- .../RecommendationConfigurationTest.java | 5 +++ .../handlers/AICoreShowcaseHandler.java | 5 +-- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index e7ea5d5..36e9634 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -91,9 +91,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { .getCdsRuntime() .getEnvironment() .getProperty( - "cds.ai.recommendations.contextRowLimit", - Integer.class, - DEFAULT_CONTEXT_ROW_LIMIT); + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); var builder = new RecommendationContextBuilder(target, rowType, limit); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 879f0fe..afde35c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -4,8 +4,6 @@ package com.sap.cds.feature.recommendation; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.feature.recommendation.api.RptInferenceClient; @@ -14,6 +12,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,21 +33,29 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { return; } + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = - aiCoreService instanceof MockAICoreServiceImpl - ? service -> new MockRecommendationClient() - : RecommendationConfiguration::resolveRptClient; + hasBind + ? RecommendationConfiguration::resolveRptClient + : service -> new MockRecommendationClient(); FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + private static RecommendationClient resolveRptClient(AICoreService service) { String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient( - service.inferenceClient(resourceGroup, deploymentId), - ((AbstractAICoreService) service).getRetry()); + return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 2865dd3..c6e4c3a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -14,7 +14,10 @@ import com.sap.cds.CdsData; import com.sap.cds.services.draft.Drafts; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,9 +36,7 @@ * AICoreService service = ...; * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - * RptInferenceClient client = - * new RptInferenceClient(service.inferenceClient(rg, deploymentId), - * ((AbstractAICoreService) service).getRetry()); + * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); * List predictions = client.predict(rows, List.of("targetColumn"), "ID"); * } */ @@ -46,13 +47,13 @@ public class RptInferenceClient implements RecommendationClient { private static final Set MANAGED_FIELDS = Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); + private static final Retry INFERENCE_RETRY = buildInferenceRetry(); + private final DefaultApi api; - private final Retry retry; - public RptInferenceClient(ApiClient apiClient, Retry retry) { + public RptInferenceClient(ApiClient apiClient) { this.api = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); - this.retry = retry; } @Override @@ -64,7 +65,7 @@ public List predict( rows.size(), predictionColumns.size()); return Retry.decorateSupplier( - retry, + INFERENCE_RETRY, () -> { var response = api.predict(request); logger.debug("Prediction response id: {}", response.getId()); @@ -115,4 +116,20 @@ private static PredictRequestPayload buildRequest( .rows(sdkRows) .indexColumn(indexColumn); } + + private static Retry buildInferenceRetry() { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(3) + .intervalFunction(IntervalFunction.ofExponentialBackoff(500, 2.0, 5000)) + .retryOnException( + e -> + e instanceof OpenApiRequestException oae + && oae.statusCode() != null + && (oae.statusCode() == 403 + || oae.statusCode() == 404 + || oae.statusCode() == 412)) + .build(); + return Retry.of("rpt-inference", config); + } } diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index a4173e3..69b5062 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -7,8 +7,10 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -20,12 +22,15 @@ class RecommendationConfigurationTest { @Mock private CdsRuntimeConfigurer configurer; @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; + @Mock private CdsEnvironment environment; @Mock private AICoreService aiCoreService; @Test void aiCoreServiceFound_registersHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) .thenReturn(aiCoreService); diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 8023ca2..ebaa19f 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -3,7 +3,6 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -140,9 +139,7 @@ public void onPredictCategory(EventContext context) { String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient( - service.inferenceClient(rg, deploymentId), - ((AbstractAICoreService) service).getRetry()); + new RptInferenceClient(service.inferenceClient(rg, deploymentId)); List predictions = client.predict(rows, List.of("category"), "ID"); List> results = new ArrayList<>(); From ee1bda72ddba018c59e7df845239a126e3bac3f8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 10:59:17 +0200 Subject: [PATCH 37/43] test(ai-core): update tests for new component architecture Adapt all unit and integration tests to use AICoreConfig, AICoreClients, DeploymentResolver instead of service accessors. --- .../core/AICoreServiceConfigurationTest.java | 14 +- .../AICoreServiceImplDeploymentIdTest.java | 75 ++++++---- .../aicore/core/AICoreServiceImplTest.java | 138 ++++++++++-------- .../aicore/core/AICoreSetupHandlerTest.java | 74 +++++----- .../core/MockAICoreServiceImplTest.java | 129 +++++++--------- .../handler/ConfigurationHandlerTest.java | 38 +++-- .../core/handler/DeploymentHandlerTest.java | 30 ++-- .../handler/ResourceGroupHandlerTest.java | 61 ++++---- .../core/handler/TenantScopingTest.java | 31 ++-- coverage-report/pom.xml | 26 ++-- .../aicore/itest/mt/MtxLifecycleTest.java | 15 +- .../itest/mt/SubscribeUnsubscribeTest.java | 25 +--- .../aicore/itest/mt/TenantIsolationTest.java | 42 +++--- .../aicore/itest/AICoreServiceTest.java | 56 +++---- .../cds/feature/aicore/itest/ActionTest.java | 60 +++----- .../aicore/itest/BaseIntegrationTest.java | 12 +- .../aicore/itest/ConfigurationTest.java | 9 +- .../feature/aicore/itest/DeploymentTest.java | 15 +- .../aicore/itest/MultiTenancyTest.java | 76 ++-------- .../aicore/itest/ResourceGroupTest.java | 9 +- 20 files changed, 420 insertions(+), 515 deletions(-) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index 77f7a2c..c6612a6 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -17,13 +17,13 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Since the test runtime has no service bindings, the configuration always registers a {@link - * MockAICoreServiceImpl} regardless of environment variables. + *

Since the test runtime has no service bindings, the configuration always registers an {@link + * AICoreServiceImpl} with mock handlers regardless of environment variables. */ class AICoreServiceConfigurationTest { @Test - void noBinding_noMultiTenancy_registersMockService() { + void noBinding_noMultiTenancy_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) .cdsModel("edmx/csn.json") @@ -34,12 +34,11 @@ void noBinding_noMultiTenancy_registersMockService() { AICoreService service = runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); - assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isFalse(); + assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); } @Test - void noBinding_withSidecarUrl_registersMultiTenantMockService() { + void noBinding_withSidecarUrl_registersService() { CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); @@ -57,8 +56,7 @@ void noBinding_withSidecarUrl_registersMultiTenantMockService() { AICoreService service = runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); - assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isTrue(); + assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); } @Test diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index a2e6a58..c4550cc 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -31,6 +31,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -38,9 +39,9 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for the happy paths of {@link AICoreServiceImpl#deploymentId(String, - * ModelDeploymentSpec)}: cache hit on a RUNNING deployment, stale-cache invalidation when the - * cached deployment is gone, and reuse of an existing matching deployment found via query. + * Unit tests for the happy paths of deployment ID resolution: cache hit on a RUNNING deployment, + * stale-cache invalidation when the cached deployment is gone, and reuse of an existing matching + * deployment found via query. */ class AICoreServiceImplDeploymentIdTest { @@ -53,12 +54,13 @@ class AICoreServiceImplDeploymentIdTest { private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; private AICoreServiceImpl service; + private DeploymentResolver resolver; private final ModelDeploymentSpec spec = new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); private String cacheKey() { - return AICoreServiceImpl.deploymentCacheKey(RG, spec); + return DeploymentResolver.deploymentCacheKey(RG, spec); } /** @@ -74,17 +76,15 @@ private AICoreServiceImpl createService(boolean multiTenancy) { configurer.cdsModel("edmx/csn.json"); CdsRuntime runtime = configurer.getCdsRuntime(); - AICoreServiceImpl svc = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - multiTenancy, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi); + + AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(svc); - configurer.eventHandler(new AICoreApiHandler()); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.complete(); return svc; } @@ -98,8 +98,8 @@ void setUp() { } @Test - void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() { - service.getResourceGroupDeploymentCache().put(cacheKey(), DEPLOYMENT_ID); + void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { + putInDeploymentCache(resolver, cacheKey(), DEPLOYMENT_ID); AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class); when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); @@ -114,9 +114,9 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() { } @Test - void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { + void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Exception { String otherDeployment = "dep-456"; - service.getResourceGroupDeploymentCache().put(cacheKey(), "stale-id"); + putInDeploymentCache(resolver, cacheKey(), "stale-id"); OpenApiRequestException notFound = new OpenApiRequestException("gone").statusCode(404); when(deploymentApi.get(RG, "stale-id")).thenThrow(notFound); @@ -133,28 +133,26 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(otherDeployment); - assertThat(service.getResourceGroupDeploymentCache()) - .containsEntry(cacheKey(), otherDeployment); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); verify(deploymentApi, never()).create(any(), any()); } @Test - void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { - service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); + void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { + putInDeploymentCache(resolver, cacheKey(), "still-valid-id"); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); - assertThat(service.getResourceGroupDeploymentCache()) - .containsEntry(cacheKey(), "still-valid-id"); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); verify(deploymentApi, never()).create(any(), any()); } @Test - void noCache_existingMatchingDeployment_isReusedAndCached() { + void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { AiDeployment existing = mock(AiDeployment.class); when(existing.getId()).thenReturn(DEPLOYMENT_ID); when(existing.getConfigurationName()).thenReturn(CONFIG_NAME); @@ -167,7 +165,7 @@ void noCache_existingMatchingDeployment_isReusedAndCached() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); - assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); verify(deploymentApi, never()).create(any(), any()); verify(deploymentApi, never()).get(any(), any()); } @@ -212,7 +210,7 @@ void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { } @Test - void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { + void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws Exception { AiDeploymentList emptyList = mock(AiDeploymentList.class); when(emptyList.getResources()).thenReturn(List.of()); when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) @@ -237,11 +235,32 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); - assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); verify(configurationApi, never()).create(any(), any()); verify(deploymentApi).create(eq(RG), any()); } + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } + + @SuppressWarnings("unchecked") + private static Map getDeploymentCache(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); + } + /** Properties provider that allows overriding specific keys for test configuration. */ private static class TestPropertiesProvider extends SimplePropertiesProvider { private final Map properties = new HashMap<>(); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java index 898e1db..be5afc5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -4,12 +4,10 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; import java.util.concurrent.ConcurrentHashMap; @@ -17,12 +15,14 @@ class AICoreServiceImplTest { + private static final AICoreConfig CONFIG = new AICoreConfig("default", "cds-", 10, 300, true); + @Test void notReadyYet_topLevel403_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(403); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -30,7 +30,7 @@ void notReadyYet_topLevel412_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(412); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -38,7 +38,7 @@ void notReadyYet_topLevel404_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(404); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -46,7 +46,7 @@ void notReadyYet_topLevel500_returnsFalse() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(500); - assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse(); + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); } @Test @@ -58,7 +58,7 @@ void notReadyYet_topLevel500WrappingInner403_returnsTrue() { when(outer.statusCode()).thenReturn(500); when(outer.getCause()).thenReturn(inner); - assertThat(AICoreServiceImpl.notReadyYet(outer)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(outer)).isTrue(); } @Test @@ -66,7 +66,7 @@ void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(null); - assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse(); + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); } @Test @@ -74,7 +74,7 @@ void deploymentLocksFieldIsConcurrentHashMap() throws NoSuchFieldException { // Locks must live in a non-evicting map: a Caffeine cache could evict an entry between two // threads' lookups, causing them to synchronize on different objects for the same cache key // and race to create duplicate AI Core deployments. - Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); assertThat(field.getType()).isEqualTo(ConcurrentHashMap.class); } @@ -91,98 +91,110 @@ void concurrentHashMapComputeIfAbsentReturnsSameLockObjectForSameKey() { } @Test - void clearTenantCacheRemovesAllRelatedEntries() throws Exception { + void invalidateTenantRemovesAllRelatedEntries() throws Exception { String tenantId = "tenant-1"; String resourceGroupId = "cds-tenant-1"; - AICoreServiceImpl service = freshService(); - Cache tenantCache = readCache(service, "tenantResourceGroupCache"); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); - - tenantCache.put(tenantId, resourceGroupId); - deploymentCache.put(resourceGroupId, "deployment-id"); - deploymentLocks.put(resourceGroupId, new Object()); + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantId, resourceGroupId); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); - service.clearTenantCache(tenantId); + resolver.invalidateTenant(tenantId); - assertThat(tenantCache.asMap()).doesNotContainKey(tenantId); - assertThat(deploymentCache.asMap()).doesNotContainKey(resourceGroupId); - assertThat(deploymentLocks).doesNotContainKey(resourceGroupId); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(tenantId); + assertThat(getDeploymentCache(resolver)).doesNotContainKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).doesNotContainKey(resourceGroupId); } @Test - void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception { + void invalidateTenantLeavesOtherTenantsUntouched() throws Exception { String tenantA = "tenant-a"; String resourceGroupA = "cds-tenant-a"; String tenantB = "tenant-b"; String resourceGroupB = "cds-tenant-b"; - AICoreServiceImpl service = freshService(); - Cache tenantCache = readCache(service, "tenantResourceGroupCache"); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantA, resourceGroupA); + putInTenantCache(resolver, tenantB, resourceGroupB); + putInDeploymentCache(resolver, resourceGroupA, "deployment-a"); + putInDeploymentCache(resolver, resourceGroupB, "deployment-b"); + putInDeploymentLocks(resolver, resourceGroupA); + putInDeploymentLocks(resolver, resourceGroupB); - tenantCache.put(tenantA, resourceGroupA); - tenantCache.put(tenantB, resourceGroupB); - deploymentCache.put(resourceGroupA, "deployment-a"); - deploymentCache.put(resourceGroupB, "deployment-b"); - deploymentLocks.put(resourceGroupA, new Object()); - deploymentLocks.put(resourceGroupB, new Object()); + resolver.invalidateTenant(tenantA); - service.clearTenantCache(tenantA); - - assertThat(tenantCache.asMap()).doesNotContainKey(tenantA).containsKey(tenantB); - assertThat(deploymentCache.asMap()) + assertThat(resolver.getTenantResourceGroupCacheView()) + .doesNotContainKey(tenantA) + .containsKey(tenantB); + assertThat(getDeploymentCache(resolver)) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + assertThat(getDeploymentLocks(resolver)) .doesNotContainKey(resourceGroupA) .containsKey(resourceGroupB); - assertThat(deploymentLocks).doesNotContainKey(resourceGroupA).containsKey(resourceGroupB); } @Test - void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { + void invalidateTenantIsNoOpForUnknownTenant() throws Exception { String resourceGroupId = "cds-tenant-1"; - AICoreServiceImpl service = freshService(); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); + DeploymentResolver resolver = freshResolver(); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); - deploymentCache.put(resourceGroupId, "deployment-id"); - deploymentLocks.put(resourceGroupId, new Object()); + resolver.invalidateTenant("unknown-tenant"); - service.clearTenantCache("unknown-tenant"); + assertThat(getDeploymentCache(resolver)).containsKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).containsKey(resourceGroupId); + } - assertThat(deploymentCache.asMap()).containsKey(resourceGroupId); - assertThat(deploymentLocks).containsKey(resourceGroupId); + private static DeploymentResolver freshResolver() { + DeploymentApi deploymentApi = mock(DeploymentApi.class); + return new DeploymentResolver(CONFIG, deploymentApi); } - private static AICoreServiceImpl freshService() throws Exception { - AICoreServiceImpl service = mock(AICoreServiceImpl.class, CALLS_REAL_METHODS); - setField(service, "tenantResourceGroupCache", Caffeine.newBuilder().build()); - setField(service, "resourceGroupDeploymentCache", Caffeine.newBuilder().build()); - setField(service, "deploymentLocks", new ConcurrentHashMap<>()); - return service; + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); } @SuppressWarnings("unchecked") - private static Cache readCache(AICoreServiceImpl service, String fieldName) + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); field.setAccessible(true); - return (Cache) field.get(service); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); } @SuppressWarnings("unchecked") - private static ConcurrentHashMap readLocks(AICoreServiceImpl service) + private static java.util.Map getDeploymentCache(DeploymentResolver resolver) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); field.setAccessible(true); - return (ConcurrentHashMap) field.get(service); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); } - private static void setField(Object target, String fieldName, Object value) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + private static void putInDeploymentLocks(DeploymentResolver resolver, String key) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap locks = + (ConcurrentHashMap) field.get(resolver); + locks.put(key, new Object()); + } + + @SuppressWarnings("unchecked") + private static ConcurrentHashMap getDeploymentLocks(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); field.setAccessible(true); - field.set(target, value); + return (ConcurrentHashMap) field.get(resolver); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java index f475e5e..6c060b5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -12,15 +12,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.cds.services.ServiceException; import com.sap.cds.services.mt.UnsubscribeEventContext; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; -import java.util.HashMap; +import java.lang.reflect.Field; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,31 +36,37 @@ class AICoreSetupHandlerTest { private static final String TENANT = "tenant-1"; private static final String RG_ID = "cds-tenant-1"; - @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; @Mock private UnsubscribeEventContext unsubscribeContext; - private Map tenantCache; + private DeploymentResolver resolver; + private AICoreClients clients; private AICoreSetupHandler cut; @BeforeEach void setUp() { - tenantCache = new HashMap<>(); - when(service.getTenantResourceGroupCache()).thenReturn(tenantCache); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + clients = + new AICoreClients( + deploymentApi, + mock(ConfigurationApi.class), + resourceGroupApi, + mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi); when(unsubscribeContext.getTenant()).thenReturn(TENANT); - cut = new AICoreSetupHandler(service); + cut = new AICoreSetupHandler(clients, resolver); } @Test - void cacheHit_deletesAndClears() { - tenantCache.put(TENANT, RG_ID); + void cacheHit_deletesAndClears() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi).delete(RG_ID); verify(resourceGroupApi, never()).getAll(any(), any(), any(), any(), any(), any(), any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -73,9 +81,9 @@ void cacheMiss_fallsBackToApiAndDeletes() { cut.beforeUnsubscribe(unsubscribeContext); assertThat(labelCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + TENANT); + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=" + TENANT); verify(resourceGroupApi).delete(RG_ID); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -87,7 +95,7 @@ void cacheMissAndApiReturnsEmpty_isNoOp() { cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -99,24 +107,24 @@ void cacheMissAndApiReturnsNullResources_isNoOp() { cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void deleteReturns404_treatedAsSuccess() { - tenantCache.put(TENANT, RG_ID); + void deleteReturns404_treatedAsSuccess() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); OpenApiRequestException notFound = new OpenApiRequestException("not found").statusCode(404); when(resourceGroupApi.delete(RG_ID)).thenThrow(notFound); cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi).delete(RG_ID); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void deleteReturnsOther5xx_propagatesAsServiceException() { - tenantCache.put(TENANT, RG_ID); + void deleteReturnsOther5xx_propagatesAsServiceException() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(500); when(resourceGroupApi.delete(RG_ID)).thenThrow(serverError); @@ -124,21 +132,12 @@ void deleteReturnsOther5xx_propagatesAsServiceException() { .isInstanceOf(ServiceException.class) .hasCauseReference(serverError); // Cache still cleared in finally. - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void unsubscribeTwice_secondCallIsNoOp() { - tenantCache.put(TENANT, RG_ID); - // First call uses cache, deletes successfully and clears (we simulate clearTenantCache - // by removing from the underlying map). - org.mockito.Mockito.doAnswer( - inv -> { - tenantCache.remove(TENANT); - return null; - }) - .when(service) - .clearTenantCache(TENANT); + void unsubscribeTwice_secondCallIsNoOp() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); cut.beforeUnsubscribe(unsubscribeContext); @@ -151,7 +150,6 @@ void unsubscribeTwice_secondCallIsNoOp() { verify(resourceGroupApi, times(1)).delete(RG_ID); verify(resourceGroupApi, times(1)).getAll(any(), any(), any(), any(), any(), any(), any()); - verify(service, times(2)).clearTenantCache(TENANT); } @Test @@ -163,7 +161,6 @@ void getAllThrows_wrappedInServiceException() { .isInstanceOf(ServiceException.class) .hasCauseReference(boom); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); } @SuppressWarnings({"unchecked", "rawtypes"}) @@ -176,4 +173,13 @@ private static BckndResourceGroupList listOf(List resources) when(list.getResources()).thenReturn(resources); return list; } + + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index 0dd1660..c555b58 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -4,111 +4,84 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; import org.junit.jupiter.api.Test; +/** + * Tests for {@link MockAICoreApiHandler} verifying the mock behavior when no AI Core binding is + * present. These tests boot a real CDS runtime with mock handlers to validate end-to-end flow. + */ class MockAICoreServiceImplTest { - private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("test-rg"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("prefix-"); - return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + private AICoreService createMockService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + if (multiTenancy) { + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + props.setMultiTenancy(mt); + } + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } @Test - void defaultConstructor_setsMultiTenancyFalse() { - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - assertThat(service.isMultiTenancyEnabled()).isFalse(); + void noMultiTenancy_resourceGroupReturnsDefault() { + AICoreService service = createMockService(false); + assertThat(service.resourceGroup()).isEqualTo("default"); } @Test - void mtConstructor_setsMultiTenancyTrue() { - MockAICoreServiceImpl service = createService(true); - assertThat(service.isMultiTenancyEnabled()).isTrue(); + void noMultiTenancy_resourceGroupForTenant_returnsDefault() { + AICoreService service = createMockService(false); + assertThat(service.resourceGroupForTenant("any-tenant")).isEqualTo("default"); } @Test - void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg"); - assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg"); - } - - @Test - void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() { - MockAICoreServiceImpl service = createService(true); + void multiTenancy_resourceGroupForTenant_returnsPrefixed() { + AICoreService service = createMockService(true); String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("prefix-my-tenant"); + assertThat(rg).isEqualTo("cds-my-tenant"); } @Test - void resourceGroupForTenant_mtEnabled_cachesResult() { - MockAICoreServiceImpl service = createService(true); + void multiTenancy_resourceGroupForTenant_cachesResult() { + AICoreService service = createMockService(true); String first = service.resourceGroupForTenant("t1"); String second = service.resourceGroupForTenant("t1"); - assertThat(first).isSameAs(second); - assertThat(service.getTenantResourceGroupCache()).containsKey("t1"); - } - - @Test - void clearTenantCache_removesCorrectEntries() { - MockAICoreServiceImpl service = createService(true); - service.resourceGroupForTenant("t1"); - service.resourceGroupForTenant("t2"); - var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec( - "scenario", "exec", "cfg1", java.util.List.of(), d -> true); - service.deploymentId("prefix-t1", spec); - - service.clearTenantCache("t1"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1"); - assertThat(service.getTenantResourceGroupCache()).containsKey("t2"); - assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1"); - } - - @Test - void getRetry_returnsNonNull() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getRetry()).isNotNull(); + assertThat(first).isEqualTo(second); } @Test - void getDefaultResourceGroup_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + void deploymentId_returnsMockId() { + AICoreService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + String id = service.deploymentId("default", spec); + assertThat(id).startsWith("mock-deployment-"); } @Test - void getResourceGroupPrefix_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + void deploymentId_cachesSameResult() { + AICoreService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + String first = service.deploymentId("default", spec); + String second = service.deploymentId("default", spec); + assertThat(first).isEqualTo(second); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 0c7b201..715ee8a 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -20,10 +20,13 @@ import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.cds.Result; -import com.sap.cds.ql.Insert; -import com.sap.cds.ql.Select; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; @@ -54,23 +57,20 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); DeploymentApi deploymentApi = mock(DeploymentApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); configurer.cdsModel("edmx/csn.json"); runtime = configurer.getCdsRuntime(); - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - /* multiTenancy */ false, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new ConfigurationHandler(configurationApi, resourceGroupApi)); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients)); configurer.complete(); } @@ -100,9 +100,7 @@ void onRead_returnsConfigurationsForResourceGroup() { ctx -> service.run( Select.from("AICore.configurations") - .where( - c -> - c.get("resourceGroup_resourceGroupId").eq("default")))); + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any()); assertThat(result.list()).hasSize(1); @@ -125,9 +123,7 @@ void onRead_nullResources_returnsEmptyList() { ctx -> service.run( Select.from("AICore.configurations") - .where( - c -> - c.get("resourceGroup_resourceGroupId").eq("default")))); + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); assertThat(result.list()).isEmpty(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index d49b187..52e50e8 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -21,10 +21,13 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.Result; -import com.sap.cds.ql.Insert; -import com.sap.cds.ql.Update; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.environment.CdsProperties; @@ -58,23 +61,20 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); configurer.cdsModel("edmx/csn.json"); runtime = configurer.getCdsRuntime(); - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - /* multiTenancy */ false, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new DeploymentHandler(deploymentApi, resourceGroupApi)); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients)); configurer.complete(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index ede41ac..b0c0fae 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -22,11 +22,14 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; @@ -57,23 +60,20 @@ static void bootRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); configurer.cdsModel("edmx/csn.json"); runtime = configurer.getCdsRuntime(); - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - /* multiTenancy */ false, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new ResourceGroupHandler(resourceGroupApi)); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients)); configurer.complete(); } @@ -90,8 +90,7 @@ void onRead_returnsAllResourceGroups() { BckndResourceGroupList list = mock(BckndResourceGroupList.class); when(list.getResources()).thenReturn(List.of(rg)); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(list); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenReturn(list); Result result = runtime @@ -141,7 +140,7 @@ void onCreate_withTenantId_setsTenantLabel() { verify(resourceGroupApi).create(captor.capture()); assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); + .containsExactly(tuple(AICoreConfig.TENANT_LABEL_KEY, "tenant-a")); } @Test @@ -159,9 +158,7 @@ void onUpdate_withLabels_callsPatchWithLabels() { service.run( Update.entity("AICore.resourceGroups") .where(d -> d.get("resourceGroupId").eq("rg-upd")) - .data( - "labels", - List.of(Map.of("key", "env", "value", "staging"))))); + .data("labels", List.of(Map.of("key", "env", "value", "staging"))))); ArgumentCaptor captor = ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); @@ -216,18 +213,16 @@ static void bootMtRuntime() { configurer.cdsModel("edmx/csn.json"); mtRuntime = configurer.getCdsRuntime(); - mtService = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - mtRuntime, - /* multiTenancy */ true, - deploymentApi, - configurationApi, - mtResourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, mtResourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + + mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); configurer.service(mtService); - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new ResourceGroupHandler(mtResourceGroupApi)); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients)); configurer.complete(); } @@ -261,7 +256,7 @@ void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { verify(mtResourceGroupApi) .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); assertThat(selectorCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant"); } } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index b35cd10..ab3f9ec 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -19,9 +18,12 @@ import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.cds.Result; -import com.sap.cds.ql.Select; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Select; import com.sap.cds.services.ServiceException; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; @@ -52,23 +54,20 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); configurer.cdsModel("edmx/csn.json"); runtime = configurer.getCdsRuntime(); - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - /* multiTenancy */ true, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); - configurer.eventHandler(new AICoreApiHandler()); - configurer.eventHandler(new DeploymentHandler(deploymentApi, resourceGroupApi)); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients)); configurer.complete(); } @@ -247,7 +246,7 @@ void emptyLabelsOnResourceGroup_throws404() { private void stubResourceGroupWithTenant(String rgId, String tenantId) { BckndResourceGroup rg = mock(BckndResourceGroup.class); BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getKey()).thenReturn(AICoreConfig.TENANT_LABEL_KEY); when(label.getValue()).thenReturn(tenantId); when(rg.getLabels()).thenReturn(List.of(label)); when(resourceGroupApi.get(rgId)).thenReturn(rg); diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml index e32cb96..8eca6c5 100644 --- a/coverage-report/pom.xml +++ b/coverage-report/pom.xml @@ -34,19 +34,6 @@ - - - mtx-integration-tests - - - com.sap.cds - cds-feature-ai-integration-tests-mtx-local - test - - - - - @@ -215,4 +202,17 @@ + + + mtx-integration-tests + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + test + + + + + diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java index 01a8d83..6e88c40 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -49,30 +48,28 @@ void tearDown() { @Test void unsubscribe_isIdempotent() throws Exception { - AbstractAICoreService service = getService(); - subscriptionEndpointClient.subscribeTenant(TENANT); subscriptionEndpointClient.unsubscribeTenant(TENANT); assertThatCode(() -> subscriptionEndpointClient.unsubscribeTenant(TENANT)) .doesNotThrowAnyException(); - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); } @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); - assertThat(service.getTenantResourceGroupCache()).containsKey(TENANT); + // After subscribe, the service should resolve a resource group for this tenant + String rg = service.resourceGroupForTenant(TENANT); + assertThat(rg).isNotNull().isNotBlank(); subscriptionEndpointClient.unsubscribeTenant(TENANT); - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); } } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java index a38467a..eb1b9d2 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -51,25 +50,13 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); - assertThat(service.isMultiTenancyEnabled()).isTrue(); - assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); - } - - @Test - void unsubscribeTenant_clearsCaches() throws Exception { - AbstractAICoreService service = getService(); - - subscriptionEndpointClient.subscribeTenant("tenant-3"); - - assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); - - subscriptionEndpointClient.unsubscribeTenant("tenant-3"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-3"); + // After subscription, the service should be able to resolve a resource group for the tenant + String resourceGroup = service.resourceGroupForTenant("tenant-3"); + assertThat(resourceGroup).isNotNull().isNotBlank(); } @Test @@ -95,7 +82,7 @@ void tearDown() { } } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java index 9b1c771..7aa06b8 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -7,8 +7,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -37,19 +38,19 @@ void setup() { @Test void multiTenancyEnabled() { - AbstractAICoreService service = getService(); - assertThat(service.isMultiTenancyEnabled()).isTrue(); + AICoreConfig config = getConfig(); + assertThat(config.multiTenancyEnabled()).isTrue(); } @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.getTenantResourceGroupCache().get("tenant-1"); - String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); + String rg1 = service.resourceGroupForTenant("tenant-1"); + String rg2 = service.resourceGroupForTenant("tenant-2"); assertThat(rg1).isNotNull(); assertThat(rg2).isNotNull(); @@ -58,31 +59,24 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { - AbstractAICoreService service = getService(); + AICoreConfig config = getConfig(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.getTenantResourceGroupCache().get("tenant-1"); + String rg = service.resourceGroupForTenant("tenant-1"); - assertThat(rg).startsWith(service.getResourceGroupPrefix()); + assertThat(rg).startsWith(config.resourceGroupPrefix()); } - @Test - void clearTenantCache_onlyAffectsTarget() throws Exception { - AbstractAICoreService service = getService(); - - subscriptionEndpointClient.subscribeTenant("tenant-1"); - subscriptionEndpointClient.subscribeTenant("tenant-2"); - - String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); - - service.clearTenantCache("tenant-1"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-1"); - assertThat(service.getTenantResourceGroupCache()).containsEntry("tenant-2", rg2); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreConfig getConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); } @AfterEach diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java index 955364e..ce0c89b 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -6,7 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -28,36 +28,34 @@ void service_isRegistered() { @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (!service.isMultiTenancyEnabled()) { + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + if (!config.multiTenancyEnabled()) { String result = service.resourceGroupForTenant("any-tenant"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (service.isMultiTenancyEnabled()) { + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + if (config.multiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); - try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); - assertThat(resourceGroupId).contains(tenantId); + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); - // Second call should return cached value - String cached = service.resourceGroupForTenant(tenantId); - assertThat(cached).isEqualTo(resourceGroupId); - } finally { - service.clearTenantCache(tenantId); - } + // Second call should return cached value + String cached = service.resourceGroupForTenant(tenantId); + assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(deploymentId).isNotNull().isNotBlank(); @@ -67,26 +65,10 @@ void deploymentId_returnsDeploymentId() { assertThat(cached).isEqualTo(deploymentId); } - @Test - void clearTenantCache_removesEntries() { - AbstractAICoreService service = getAICoreServiceImpl(); - String tenantId = "itest-cache-tenant"; - String fakeRg = "fake-rg"; - String fakeKey = fakeRg + "::" + RptModelSpec.CONFIG_NAME; - service.getTenantResourceGroupCache().put(tenantId, fakeRg); - service.getResourceGroupDeploymentCache().put(fakeKey, "fake-deployment"); - - service.clearTenantCache(tenantId); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantId); - assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKey(fakeKey); - } - @Test void configProperties_areApplied() { - AbstractAICoreService service = getAICoreServiceImpl(); - assertThat(service.getRetry()).isNotNull(); - assertThat(service.getDefaultResourceGroup()).isNotBlank(); - assertThat(service.getResourceGroupPrefix()).isNotBlank(); + AICoreConfig config = getAICoreConfig(); + assertThat(config.defaultResourceGroup()).isNotBlank(); + assertThat(config.resourceGroupPrefix()).isNotBlank(); } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java index cc7e592..0e7bf34 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -10,7 +10,7 @@ import com.sap.cds.Result; import com.sap.cds.Row; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; @@ -28,35 +28,33 @@ class ActionTest extends BaseIntegrationTest { @BeforeAll void ensureResourceGroupReady() { - ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreServiceImpl().getDefaultResourceGroup()); + ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreConfig().defaultResourceGroup()); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); String result = service.resourceGroupForTenant("any-tenant-id"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + assertThat(result).isEqualTo(config.defaultResourceGroup()); } @Test void resourceGroupForTenant_multiTenancy_createsGroup() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); - try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); - assertThat(resourceGroupId).contains(tenantId); - } finally { - service.clearTenantCache(tenantId); - } + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); } @Test void deploymentId_returnsValidDeployment() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(deploymentId).isNotNull().isNotBlank(); @@ -64,20 +62,21 @@ void deploymentId_returnsValidDeployment() { @Test void deploymentId_cachedOnSecondCall() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(second).isEqualTo(first); } - @Disabled("Stops the shared RPT deployment needed by subsequent Recommendation tests; " - + "re-enable once test creates its own isolated deployment") + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") @Test void stop_deployment_changesTargetStatus() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( @@ -99,7 +98,8 @@ void stop_deployment_changesTargetStatus() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( @@ -114,20 +114,4 @@ void stop_deployment_changesTargetStatus() { Row row = readResult.single(); assertThat(row.get("targetStatus")).isIn("STOPPED", "STOPPING"); } - - @Test - void resolveResourceGroupFromKeys_directKey() { - AbstractAICoreService service = getAICoreServiceImpl(); - Map keys = Map.of("resourceGroup_resourceGroupId", "my-rg"); - String resolved = service.resolveResourceGroupFromKeys(keys); - assertThat(resolved).isEqualTo("my-rg"); - } - - @Test - void resolveResourceGroupFromKeys_nestedMap() { - AbstractAICoreService service = getAICoreServiceImpl(); - Map keys = Map.of("resourceGroup", Map.of("resourceGroupId", "nested-rg")); - String resolved = service.resolveResourceGroupFromKeys(keys); - assertThat(resolved).isEqualTo("nested-rg"); - } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index b474950..06a09af 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -5,12 +5,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; import java.util.Map; @@ -42,8 +43,11 @@ protected AICoreService getAICoreService() { return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } - protected AbstractAICoreService getAICoreServiceImpl() { - return (AbstractAICoreService) getAICoreService(); + protected AICoreConfig getAICoreConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); } protected CqnService getAICoreCqnService() { @@ -51,7 +55,7 @@ protected CqnService getAICoreCqnService() { } protected String ensureRptDeploymentReady() { - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); return CACHED_DEPLOYMENT_IDS.computeIfAbsent( resourceGroup, rg -> { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java index ff45c9e..2b1e5b5 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -7,7 +7,6 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.services.cds.CqnService; @@ -20,7 +19,7 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -32,7 +31,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -48,7 +47,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); Result created = @@ -93,7 +92,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); Result created = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java index 77f5502..d73fd50 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -8,7 +8,6 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; import com.sap.cds.services.cds.CqnService; @@ -21,7 +20,7 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.deployments") @@ -33,7 +32,7 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result all = service.run( Select.from("AICore.deployments") @@ -58,12 +57,13 @@ void readSingle_returnsDeploymentDetails() { assertThat(row.get("status")).isNotNull(); } - @Disabled("Stops the shared RPT deployment needed by subsequent Recommendation tests; " - + "re-enable once test creates its own isolated deployment") + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") @Test void update_targetStatus_stopsRunningDeployment() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( @@ -85,7 +85,8 @@ void update_targetStatus_stopsRunningDeployment() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java index c845f13..2716d06 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -8,34 +8,18 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import org.junit.jupiter.api.AfterEach; +import com.sap.cds.feature.aicore.core.AICoreConfig; import org.junit.jupiter.api.Test; class MultiTenancyTest extends BaseIntegrationTest { - private String tenantA; - private String tenantB; - - @AfterEach - void cleanup() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (tenantA != null) { - service.clearTenantCache(tenantA); - tenantA = null; - } - if (tenantB != null) { - service.clearTenantCache(tenantB); - tenantB = null; - } - } - @Test void differentTenants_getDifferentResourceGroups() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-mt-a-" + System.currentTimeMillis(); - tenantB = "itest-mt-b-" + System.currentTimeMillis(); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-mt-a-" + System.currentTimeMillis(); + String tenantB = "itest-mt-b-" + System.currentTimeMillis(); String rgA = service.resourceGroupForTenant(tenantA); String rgB = service.resourceGroupForTenant(tenantB); @@ -47,52 +31,24 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-prefix-" + System.currentTimeMillis(); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-prefix-" + System.currentTimeMillis(); String rg = service.resourceGroupForTenant(tenantA); - assertThat(rg).startsWith(service.getResourceGroupPrefix()); - } - - @Test - void cacheIsolation_perTenant() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-cache-a-" + System.currentTimeMillis(); - tenantB = "itest-cache-b-" + System.currentTimeMillis(); - - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); - - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantA, rgA); - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); - } - - @Test - void clearTenantCache_onlyAffectsTargetTenant() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-clear-a-" + System.currentTimeMillis(); - tenantB = "itest-clear-b-" + System.currentTimeMillis(); - - service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); - - service.clearTenantCache(tenantA); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantA); - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); + assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); String rg1 = service.resourceGroupForTenant("tenant-x"); String rg2 = service.resourceGroupForTenant("tenant-y"); - assertThat(rg1).isEqualTo(service.getDefaultResourceGroup()); - assertThat(rg2).isEqualTo(service.getDefaultResourceGroup()); + assertThat(rg1).isEqualTo(config.defaultResourceGroup()); + assertThat(rg2).isEqualTo(config.defaultResourceGroup()); } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java index 2d32455..e6ca394 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -125,9 +125,12 @@ void delete_resourceGroup() throws InterruptedException { waitForResourceGroupProvisioned(service, rgId); - assertThatCode(() -> - service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))) - ).doesNotThrowAnyException(); + assertThatCode( + () -> + service.run( + Delete.from("AICore.resourceGroups") + .where(r -> r.get("resourceGroupId").eq(rgId)))) + .doesNotThrowAnyException(); createdResourceGroupId = null; // already deleted } From 7d8d2f7c40da425eaa5f3effbcbfb8898be676fb Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 11:01:54 +0200 Subject: [PATCH 38/43] for pipeline --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 198ed92..59dd156 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,7 +9,7 @@ permissions: on: workflow_dispatch: pull_request: - branches: [main] + branches: [main, refactor/remote-service-migration-v2] types: [reopened, synchronize, opened] jobs: From 7966da8c00ebd4ea77d3322a66dbc53297eefdcb Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:00:09 +0200 Subject: [PATCH 39/43] fix(ai-core): separate retry boundary to prevent orphaned deployments Only retry the deployment creation call. Polling is handled separately so a poll timeout does not re-create deployments. --- .../aicore/core/DeploymentResolver.java | 85 +++++++++++--- .../aicore/core/handler/AICoreApiHandler.java | 111 ++++++------------ 2 files changed, 104 insertions(+), 92 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java index 48a526c..1db6136 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java @@ -6,16 +6,22 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; import java.time.Duration; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,30 +54,44 @@ public class DeploymentResolver { */ private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); + private final AICoreConfig config; private final DeploymentApi deploymentApi; + private final ResourceGroupApi resourceGroupApi; private final Retry retry; - public DeploymentResolver(AICoreConfig config, DeploymentApi deploymentApi) { + public DeploymentResolver( + AICoreConfig config, DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { + this.config = config; this.deploymentApi = deploymentApi; + this.resourceGroupApi = resourceGroupApi; this.retry = buildRetry(config.maxRetries(), config.initialDelayMs()); this.tenantResourceGroupCache = newCache(); this.deploymentCache = newCache(); } + // ────────────────────────────────────────────────────────────────────────── + // Resource group resolution + // ────────────────────────────────────────────────────────────────────────── + /** - * Resolves the resource group for a tenant. On cache miss, calls the {@code creator} function - * (which typically creates the resource group in AI Core) atomically via Caffeine's loader. - * Thread-safe. + * Resolves the resource group for a tenant. Returns the configured default resource group if + * multi-tenancy is disabled or tenant is {@code null}. Otherwise looks up (or creates) the + * tenant's resource group via the AI Core API, caching the result. Thread-safe. * - * @param tenantId the CDS tenant identifier - * @param creator function that receives the tenant ID and returns the resource group ID (may - * involve API calls to find or create the resource group) + * @param tenantId the CDS tenant identifier (may be {@code null}) * @return the AI Core resource group ID */ - public String resolveResourceGroup(String tenantId, Function creator) { - return tenantResourceGroupCache.get(tenantId, creator::apply); + public String resolveResourceGroup(String tenantId) { + if (!config.multiTenancyEnabled() || tenantId == null) { + return config.defaultResourceGroup(); + } + return tenantResourceGroupCache.get(tenantId, this::findOrCreateResourceGroup); } + // ────────────────────────────────────────────────────────────────────────── + // Deployment resolution + // ────────────────────────────────────────────────────────────────────────── + /** * Resolves a deployment ID for the given spec within a resource group. On cache hit, validates * via {@link DeploymentApi#get} that the deployment is still RUNNING or PENDING. On cache miss or @@ -104,6 +124,10 @@ public String resolveDeployment( } } + // ────────────────────────────────────────────────────────────────────────── + // Cache management + // ────────────────────────────────────────────────────────────────────────── + /** * Evicts all cache entries associated with the given tenant: the resource-group mapping, all * deployments in that resource group, and their lock entries. @@ -126,18 +150,15 @@ public Retry getRetry() { } /** - * Returns a read-only view of the tenant-to-resource-group cache. Primarily for diagnostics and - * the setup handler's unsubscribe logic. + * Returns an unmodifiable view of the tenant-to-resource-group cache. Primarily for diagnostics + * and the setup handler's unsubscribe logic. */ public Map getTenantResourceGroupCacheView() { - return tenantResourceGroupCache.asMap(); + return Collections.unmodifiableMap(tenantResourceGroupCache.asMap()); } - /** - * Builds the cache key for deployment lookups. Public so that tests can derive the same key - * format. - */ - public static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + /** Builds the cache key for deployment lookups. */ + static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { return resourceGroupId + "::" + spec.configurationName(); } @@ -160,6 +181,34 @@ public static boolean notReadyYet(OpenApiRequestException e) { // Internal // ────────────────────────────────────────────────────────────────────────── + private String findOrCreateResourceGroup(String tenantId) { + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = + resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + String resourceGroupId = config.resourceGroupPrefix() + tenantId; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + resourceGroupApi.create(request); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 409) { + logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId); + } else { + throw e; + } + } + return resourceGroupId; + } + /** * Validates that a cached deployment ID is still active (RUNNING or PENDING). Returns {@code * true} if valid, {@code false} if stale (404). Throws on unexpected errors so the caller's diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java index 146a57a..3ecf8e7 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -10,10 +10,6 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; -import com.sap.ai.sdk.core.model.BckndResourceGroupList; -import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; @@ -28,11 +24,9 @@ import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; -import java.util.List; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,9 +35,8 @@ * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, * {@code inferenceClient}). * - *

Contains the business logic for resource-group resolution, deployment discovery/creation, and - * inference client construction. Caching, locking, and retry are delegated to {@link - * DeploymentResolver}. + *

Contains the business logic for deployment discovery/creation and inference client + * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. */ @ServiceName(AICoreService.DEFAULT_NAME) public class AICoreApiHandler implements EventHandler { @@ -66,13 +59,7 @@ public void onResourceGroup(ResourceGroupContext context) { if (tenantId == null) { tenantId = context.getUserInfo().getTenant(); } - if (!config.multiTenancyEnabled() || tenantId == null) { - logger.debug("Using default resource group {}", config.defaultResourceGroup()); - context.setResult(config.defaultResourceGroup()); - return; - } - String result = resolver.resolveResourceGroup(tenantId, this::findOrCreateResourceGroup); - context.setResult(result); + context.setResult(resolver.resolveResourceGroup(tenantId)); } @On @@ -97,38 +84,6 @@ public void onInferenceClient(InferenceClientContext context) { context.setResult(ApiClient.create(destination)); } - // ────────────────────────────────────────────────────────────────────────── - // Resource group business logic - // ────────────────────────────────────────────────────────────────────────── - - private String findOrCreateResourceGroup(String tenantId) { - List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); - BckndResourceGroupList result = - clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - return resources.get(0).getResourceGroupId(); - } - String resourceGroupId = config.resourceGroupPrefix() + tenantId; - BckndResourceGroupLabel label = - BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId); - BckndResourceGroupsPostRequest request = - BckndResourceGroupsPostRequest.create() - .resourceGroupId(resourceGroupId) - .labels(List.of(label)); - try { - clients.resourceGroupApi().create(request); - logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); - } catch (OpenApiRequestException e) { - if (e.statusCode() != null && e.statusCode() == 409) { - logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId); - } else { - throw e; - } - } - return resourceGroupId; - } - // ────────────────────────────────────────────────────────────────────────── // Deployment business logic // ────────────────────────────────────────────────────────────────────────── @@ -152,40 +107,48 @@ private String findOrCreateDeployment(String resourceGroupId, ModelDeploymentSpe } private String createDeployment(String resourceGroupId, ModelDeploymentSpec spec) { - AiConfigurationList configList = - clients - .configurationApi() - .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); - String configId = - configList.getResources().stream() - .filter(c -> spec.configurationName().equals(c.getName())) - .findFirst() - .map( - c -> { + String configId = findOrCreateConfiguration(resourceGroupId, spec); + + // Retry only the creation call — transient 403/412 on fresh resource groups. + // Once we have a deployment ID, polling is handled separately to avoid + // creating orphaned deployments on poll timeout. + String deploymentId = + Retry.decorateSupplier( + resolver.getRetry(), + () -> { + var deployRequest = + AiDeploymentCreationRequest.create().configurationId(configId); + var response = clients.deploymentApi().create(resourceGroupId, deployRequest); logger.debug( - "Reusing existing configuration {} ({}) in resource group {}", - c.getId(), + "Created deployment {} ({}) in resource group {}", + response.getId(), spec.configurationName(), resourceGroupId); - return c.getId(); + return response.getId(); }) - .orElseGet(() -> createConfiguration(resourceGroupId, spec)); + .get(); - Retry retry = resolver.getRetry(); - return Retry.decorateSupplier( - retry, - () -> { - var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); - var deployResponse = clients.deploymentApi().create(resourceGroupId, deployRequest); - String deploymentId = deployResponse.getId(); + return pollUntilRunning(resourceGroupId, deploymentId); + } + + private String findOrCreateConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { + AiConfigurationList configList = + clients + .configurationApi() + .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + return configList.getResources().stream() + .filter(c -> spec.configurationName().equals(c.getName())) + .findFirst() + .map( + c -> { logger.debug( - "Created deployment {} ({}) in resource group {}, polling for RUNNING", - deploymentId, + "Reusing existing configuration {} ({}) in resource group {}", + c.getId(), spec.configurationName(), resourceGroupId); - return pollUntilRunning(resourceGroupId, deploymentId); + return c.getId(); }) - .get(); + .orElseGet(() -> createConfiguration(resourceGroupId, spec)); } private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { @@ -207,7 +170,7 @@ private String createConfiguration(String resourceGroupId, ModelDeploymentSpec s private String pollUntilRunning(String resourceGroupId, String deploymentId) { Retry pollRetry = Retry.of( - "pollDeployment", + "pollDeployment-" + deploymentId, RetryConfig.custom() .maxAttempts(config.maxRetries()) .intervalFunction( From a136659faaa252febd430c929b1778facb73cee5 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:00:17 +0200 Subject: [PATCH 40/43] refactor(ai-core): remove all service references from handlers Handlers use DeploymentResolver.resolveResourceGroup() directly instead of casting context.getService(). Zero service references. --- .../core/AICoreServiceConfiguration.java | 15 ++++++++------- .../core/handler/AbstractCrudHandler.java | 12 +++++++----- .../aicore/core/handler/ActionHandler.java | 18 +++++++----------- .../core/handler/ConfigurationHandler.java | 6 ++++-- .../aicore/core/handler/DeploymentHandler.java | 6 ++++-- .../core/handler/ResourceGroupHandler.java | 11 ++++++----- 6 files changed, 36 insertions(+), 32 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 1a7f403..2b94030 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -93,7 +93,7 @@ public void services(CdsRuntimeConfigurer configurer) { this.clients = new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); - this.resolver = new DeploymentResolver(config, deploymentApi); + this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); logger.info("Registered AICoreService backed by AI Core binding."); } else { logger.info( @@ -112,10 +112,10 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { if (clients != null) { // Production path: real AI Core binding configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new ResourceGroupHandler(config, clients)); - configurer.eventHandler(new DeploymentHandler(config, clients)); - configurer.eventHandler(new ConfigurationHandler(config, clients)); - configurer.eventHandler(new ActionHandler(config, clients)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.eventHandler(new ActionHandler(config, clients, resolver)); logger.debug("Registered production AI Core event handlers."); if (config.multiTenancyEnabled()) { @@ -124,12 +124,13 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } else { // Mock path: no AI Core binding + MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); configurer.eventHandler(new MockEntityHandler()); - configurer.eventHandler(new MockAICoreApiHandler(config)); + configurer.eventHandler(mockApiHandler); logger.debug("Registered mock AI Core event handlers."); if (config.multiTenancyEnabled()) { - configurer.eventHandler(new MockAICoreSetupHandler(config)); + configurer.eventHandler(new MockAICoreSetupHandler(mockApiHandler)); logger.debug("Registered mock AI Core setup handler for MTX subscribe/unsubscribe."); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 2c83942..fc3392d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -4,9 +4,9 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; @@ -21,15 +21,18 @@ abstract class AbstractCrudHandler implements EventHandler { protected final AICoreConfig config; protected final AICoreClients clients; + protected final DeploymentResolver resolver; - protected AbstractCrudHandler(AICoreConfig config, AICoreClients clients) { + protected AbstractCrudHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { this.config = config; this.clients = clients; + this.resolver = resolver; } /** * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference - * in the keys before falling back to the current tenant's default resource group via the service. + * in the keys before falling back to tenant-based resolution via the {@link DeploymentResolver}. */ protected String resolveResourceGroup(EventContext context, Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { @@ -39,8 +42,7 @@ protected String resolveResourceGroup(EventContext context, Map if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { return (String) rgMap.get("resourceGroupId"); } - // Fall back to the service's resource-group resolution for the current tenant - return ((AICoreService) context.getService()).resourceGroup(); + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 4e9e465..2c9d1e7 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -8,9 +8,11 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.Map; @@ -22,13 +24,15 @@ public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); - public ActionHandler(AICoreConfig config, AICoreClients clients) { - super(config, clients); + public ActionHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } @On(entity = Deployments_.CDS_NAME) public void onStop(DeploymentsStopContext context) { - Map keys = asMap(context.get("keys")); + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(context, keys); @@ -38,12 +42,4 @@ public void onStop(DeploymentsStopContext context) { logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); context.setCompleted(); } - - @SuppressWarnings("unchecked") - private static Map asMap(Object obj) { - if (obj instanceof Map) { - return (Map) obj; - } - return Map.of(); - } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index b030a32..d7a60df 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -10,6 +10,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; @@ -36,8 +37,9 @@ public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); - public ConfigurationHandler(AICoreConfig config, AICoreClients clients) { - super(config, clients); + public ConfigurationHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } @On(entity = Configurations_.CDS_NAME) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 613bcec..6e21697 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -12,6 +12,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; @@ -40,8 +41,9 @@ public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); - public DeploymentHandler(AICoreConfig config, AICoreClients clients) { - super(config, clients); + public DeploymentHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } @On(entity = Deployments_.CDS_NAME) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index f851cd2..9471662 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -12,6 +12,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; @@ -40,8 +41,9 @@ public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); - public ResourceGroupHandler(AICoreConfig config, AICoreClients clients) { - super(config, clients); + public ResourceGroupHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } @On(entity = ResourceGroups_.CDS_NAME) @@ -156,10 +158,9 @@ private String resolveResourceGroupId(EventContext context, Map return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } if (keys.containsKey(ResourceGroups.TENANT_ID)) { - return ((AICoreService) context.getService()) - .resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + return resolver.resolveResourceGroup((String) keys.get(ResourceGroups.TENANT_ID)); } - return ((AICoreService) context.getService()).resourceGroup(); + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** From 9a71a2a476ac05cd985699c6ffb587b1685cf8bd Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:00:24 +0200 Subject: [PATCH 41/43] fix(ai-core): add handler ordering and wire mock cleanup Add @HandlerOrder to setup handlers for DeploymentService events. Wire MockAICoreSetupHandler to actually call clearTenantCache(). Use ServiceException in mock inference handler. --- .../aicore/core/AICoreSetupHandler.java | 24 ++++------------ .../aicore/core/MockAICoreSetupHandler.java | 28 ++++++++----------- .../core/handler/MockAICoreApiHandler.java | 7 +++-- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java index 5b2359d..57a56ac 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -10,6 +10,7 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.mt.SubscribeEventContext; @@ -33,13 +34,13 @@ public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) { } @After(event = DeploymentService.EVENT_SUBSCRIBE) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Creating AI Core resources for tenant {}", tenantId); try { - String resourceGroupId = - resolver.resolveResourceGroup(tenantId, this::findOrCreateResourceGroup); - logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId); + String resourceGroupId = resolver.resolveResourceGroup(tenantId); + logger.info("Ensured AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (Exception e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, @@ -50,6 +51,7 @@ public void afterSubscribe(SubscribeEventContext context) { } @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Deleting AI Core resources for tenant {}", tenantId); @@ -61,22 +63,6 @@ public void beforeUnsubscribe(UnsubscribeEventContext context) { } } - private String findOrCreateResourceGroup(String tenantId) { - List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); - BckndResourceGroupList result = - clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - return resources.get(0).getResourceGroupId(); - } - // This should not normally happen during subscribe (AICoreApiHandler creates it), - // but handle gracefully. - throw new ServiceException( - ErrorStatuses.SERVER_ERROR, - "Resource group not found for tenant {} during subscribe", - tenantId); - } - private void deleteResourceGroupForTenant(String tenantId) { String resourceGroupId = resolveResourceGroupId(tenantId); if (resourceGroupId == null) { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java index dd4c277..77a0983 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java @@ -3,10 +3,11 @@ */ package com.sap.cds.feature.aicore.core; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.mt.SubscribeEventContext; @@ -19,33 +20,26 @@ public class MockAICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class); - private final AICoreConfig config; + private final MockAICoreApiHandler mockHandler; - public MockAICoreSetupHandler(AICoreConfig config) { - this.config = config; + public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) { + this.mockHandler = mockHandler; } @After(event = DeploymentService.EVENT_SUBSCRIBE) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); - // In mock mode, resourceGroupForTenant is handled by MockAICoreApiHandler's cache; - // just emit on the service to trigger it. - AICoreService service = - context - .getCdsRuntime() - .getServiceCatalog() - .getService(AICoreService.class, AICoreService.DEFAULT_NAME); - String resourceGroupId = service.resourceGroupForTenant(tenantId); - logger.info( - "Mock created in-memory resource group {} for tenant {}", resourceGroupId, tenantId); + // Trigger resource group creation in mock cache + mockHandler.getTenantResourceGroupCache().computeIfAbsent(tenantId, id -> "cds-" + id); + logger.info("Mock created in-memory resource group for tenant {}", tenantId); } @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); - // Find the MockAICoreApiHandler to clear its cache. - // In mock mode, this is the simplest way to clean up in-memory state. - // The handler's cache is tenant-scoped so clearing one tenant is cheap. + mockHandler.clearTenantCache(tenantId); logger.info("Mock cleared in-memory caches for tenant {}", tenantId); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java index 0f99c37..f5155a2 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -9,6 +9,8 @@ import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; @@ -62,8 +64,9 @@ public void onDeploymentId(DeploymentIdContext context) { @On public void onInferenceClient(InferenceClientContext context) { - throw new UnsupportedOperationException( - "Mock AI Core does not provide an inference client; tests should stub inference."); + throw new ServiceException( + ErrorStatuses.NOT_IMPLEMENTED, + "Inference client is not available without an AI Core service binding"); } /** Returns the mock tenant cache for test inspection. */ From e0479107375fe64b3544e447e0c7db3c87ec0775 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:00:31 +0200 Subject: [PATCH 42/43] fix(ai-core): validate config at startup, document impl coupling Fail fast on invalid cds.ai.core.* property values. Document AbstractCdsDefinedService dependency rationale. --- .../cds/feature/aicore/core/AICoreConfig.java | 16 ++++++++++++++++ .../feature/aicore/core/AICoreServiceImpl.java | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java index 4670a6f..84ded92 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java @@ -29,6 +29,22 @@ public record AICoreConfig( private static final int DEFAULT_MAX_RETRIES = 10; private static final long DEFAULT_INITIAL_DELAY_MS = 300; + public AICoreConfig { + if (maxRetries < 1) { + throw new IllegalArgumentException("cds.ai.core.maxRetries must be >= 1, got " + maxRetries); + } + if (initialDelayMs < 1) { + throw new IllegalArgumentException( + "cds.ai.core.initialDelayMs must be >= 1, got " + initialDelayMs); + } + if (defaultResourceGroup == null || defaultResourceGroup.isBlank()) { + throw new IllegalArgumentException("cds.ai.core.resourceGroup must not be blank"); + } + if (resourceGroupPrefix == null) { + throw new IllegalArgumentException("cds.ai.core.resourceGroupPrefix must not be null"); + } + } + /** Creates an {@code AICoreConfig} from the runtime environment properties. */ public static AICoreConfig from(CdsEnvironment env, boolean multiTenancyEnabled) { return new AICoreConfig( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 31d57e9..b1c226c 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -19,6 +19,13 @@ * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and * returns the handler's result. All business logic (caching, locking, API calls) lives in the * registered ON handlers which receive their dependencies via constructor injection. + * + *

Implementation note: This class extends {@code AbstractCdsDefinedService} + * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public + * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding. + * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not + * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and + * version-compatibility is verified through integration tests against the CAP Java runtime. */ public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { From 56b093e4ece8872ec5a74c0fc8823969f7fd5027 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:00:36 +0200 Subject: [PATCH 43/43] test(ai-core): update tests for DeploymentResolver expansion Pass ResourceGroupApi to DeploymentResolver constructor. Pass DeploymentResolver to CRUD handler constructors. --- .../aicore/core/AICoreServiceImplDeploymentIdTest.java | 2 +- .../cds/feature/aicore/core/AICoreServiceImplTest.java | 4 +++- .../cds/feature/aicore/core/AICoreSetupHandlerTest.java | 2 +- .../aicore/core/handler/ConfigurationHandlerTest.java | 4 ++-- .../aicore/core/handler/DeploymentHandlerTest.java | 4 ++-- .../aicore/core/handler/ResourceGroupHandlerTest.java | 9 +++++---- .../feature/aicore/core/handler/TenantScopingTest.java | 4 ++-- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index c4550cc..9c66431 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -80,7 +80,7 @@ private AICoreServiceImpl createService(boolean multiTenancy) { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); - resolver = new DeploymentResolver(config, deploymentApi); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(svc); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java index be5afc5..78fb35b 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; import java.util.concurrent.ConcurrentHashMap; @@ -151,7 +152,8 @@ void invalidateTenantIsNoOpForUnknownTenant() throws Exception { private static DeploymentResolver freshResolver() { DeploymentApi deploymentApi = mock(DeploymentApi.class); - return new DeploymentResolver(CONFIG, deploymentApi); + ResourceGroupApi resourceGroupApi = mock(ResourceGroupApi.class); + return new DeploymentResolver(CONFIG, deploymentApi, resourceGroupApi); } @SuppressWarnings("unchecked") diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java index 6c060b5..a379857 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -53,7 +53,7 @@ void setUp() { mock(ConfigurationApi.class), resourceGroupApi, mock(AiCoreService.class)); - resolver = new DeploymentResolver(config, deploymentApi); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); when(unsubscribeContext.getTenant()).thenReturn(TENANT); cut = new AICoreSetupHandler(clients, resolver); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 715ee8a..7fdb3e2 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -65,12 +65,12 @@ static void bootRuntime() { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); - DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new ConfigurationHandler(config, clients)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); configurer.complete(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index 52e50e8..0e47302 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -69,12 +69,12 @@ static void bootRuntime() { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); - DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new DeploymentHandler(config, clients)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index b0c0fae..4dfc443 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -68,12 +68,12 @@ static void bootRuntime() { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); - DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new ResourceGroupHandler(config, clients)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); } @@ -217,12 +217,13 @@ static void bootMtRuntime() { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, mtResourceGroupApi, mock(AiCoreService.class)); - DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + DeploymentResolver resolver = + new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); configurer.service(mtService); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new ResourceGroupHandler(config, clients)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index ab3f9ec..078779c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -62,12 +62,12 @@ static void bootRuntime() { AICoreClients clients = new AICoreClients( deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); - DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); - configurer.eventHandler(new DeploymentHandler(config, clients)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); }