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..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 @@ -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"; @@ -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/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..19a2a82 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.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 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. + * + *

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 { + + /** 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(); + + /** Sets the resolved resource group ID. */ + void setResult(String resourceGroupId); + + /** Creates a new context instance. */ + static ResourceGroupContext create() { + return EventContext.create(ResourceGroupContext.class, null); + } +} 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..84ded92 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java @@ -0,0 +1,58 @@ +/* + * © 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; + + 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( + 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); + } +} 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..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 @@ -8,9 +8,11 @@ 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; +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; @@ -28,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(); } @@ -60,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 @@ -73,54 +81,58 @@ 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, resourceGroupApi); 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 (config == null) { + return; // No AICore model — services() skipped registration + } - if (registered instanceof AICoreServiceImpl service) { - configurer.eventHandler(new ResourceGroupHandler(service)); - configurer.eventHandler(new DeploymentHandler(service)); - configurer.eventHandler(new ConfigurationHandler(service)); - configurer.eventHandler(new ActionHandler(service)); - logger.debug("Registered Prod AI-Core Implementation"); + if (clients != null) { + // Production path: real AI Core binding + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + 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 (service.isMultiTenancyEnabled()) { - configurer.eventHandler(new AICoreSetupHandler(service)); - logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe."); + 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 + MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); 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(mockApiHandler); + logger.debug("Registered mock AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new MockAICoreSetupHandler(mockApiHandler)); + 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/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 52eeb4f..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 @@ -3,424 +3,68 @@ */ 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.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.services.environment.CdsEnvironment; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +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.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. + * 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. + *

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. * - *

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. + *

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 AbstractAICoreService { +public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); + private static final String CDS_DEFINITION_NAME = "AICore"; - 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; - - /** - * 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 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; + public AICoreServiceImpl(String name, CdsRuntime runtime) { + super(name, CDS_DEFINITION_NAME, runtime); } - private static Cache newCache() { - return Caffeine.newBuilder() - .maximumSize(DEFAULT_CACHE_MAX_SIZE) - .expireAfterAccess(DEFAULT_CACHE_EXPIRY) - .build(); + @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); - } - - @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; - } - - @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)); - } - } - - /** - * 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. - */ - 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) { - 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); + InferenceClientContext ctx = InferenceClientContext.create(); + ctx.setResourceGroupId(resourceGroupId); + ctx.setDeploymentId(deploymentId); + emit(ctx); + return ctx.getResult(); } } 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..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 @@ -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; @@ -11,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; @@ -25,19 +25,22 @@ 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) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Creating AI Core resources for tenant {}", tenantId); try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - 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, @@ -48,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); @@ -55,7 +59,7 @@ 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); } } @@ -68,7 +72,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 +96,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/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/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java new file mode 100644 index 0000000..1db6136 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java @@ -0,0 +1,252 @@ +/* + * © 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.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.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 AICoreConfig config; + private final DeploymentApi deploymentApi; + private final ResourceGroupApi resourceGroupApi; + private final Retry retry; + + 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. 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 (may be {@code null}) + * @return the AI Core resource group ID + */ + 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 + * 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; + } + } + + // ────────────────────────────────────────────────────────────────────────── + // 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. + */ + 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 an unmodifiable view of the tenant-to-resource-group cache. Primarily for diagnostics + * and the setup handler's unsubscribe logic. + */ + public Map getTenantResourceGroupCacheView() { + return Collections.unmodifiableMap(tenantResourceGroupCache.asMap()); + } + + /** Builds the cache key for deployment lookups. */ + 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 + // ────────────────────────────────────────────────────────────────────────── + + 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 + * 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); + } +} 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; - } -} 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..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,9 +3,11 @@ */ package com.sap.cds.feature.aicore.core; +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; @@ -18,24 +20,26 @@ public class MockAICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class); - private final MockAICoreServiceImpl service; + private final MockAICoreApiHandler mockHandler; - public MockAICoreSetupHandler(MockAICoreServiceImpl service) { - this.service = service; + public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) { + this.mockHandler = mockHandler; } @After(event = DeploymentService.EVENT_SUBSCRIBE) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); - 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(); - service.clearTenantCache(tenantId); + 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/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..3ecf8e7 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -0,0 +1,216 @@ +/* + * © 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.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.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.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; +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 io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +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 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 { + + private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); + + 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) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + context.setResult(resolver.resolveResourceGroup(tenantId)); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + + String deploymentId = + resolver.resolveDeployment( + resourceGroupId, spec, () -> findOrCreateDeployment(resourceGroupId, spec)); + context.setResult(deploymentId); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + var destination = + clients + .sdkService() + .getInferenceDestination(context.getResourceGroupId()) + .usingDeploymentId(context.getDeploymentId()); + logger.debug("Inference destination URI: {}", destination.getUri()); + context.setResult(ApiClient.create(destination)); + } + + // ────────────────────────────────────────────────────────────────────────── + // 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) { + 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( + "Created deployment {} ({}) in resource group {}", + response.getId(), + spec.configurationName(), + resourceGroupId); + return response.getId(); + }) + .get(); + + 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( + "Reusing existing configuration {} ({}) in resource group {}", + c.getId(), + spec.configurationName(), + resourceGroupId); + return c.getId(); + }) + .orElseGet(() -> createConfiguration(resourceGroupId, spec)); + } + + 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 = clients.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) { + Retry pollRetry = + Retry.of( + "pollDeployment-" + deploymentId, + RetryConfig.custom() + .maxAttempts(config.maxRetries()) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(config.initialDelayMs(), 2.0)) + .retryOnResult( + deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) + .retryOnException(e -> false) + .build()); + + AiDeploymentResponseWithDetails result = + Retry.decorateSupplier( + pollRetry, + () -> { + var current = clients.deploymentApi().get(resourceGroupId, deploymentId); + logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); + return current; + }) + .get(); + + if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { + return deploymentId; + } + logger.error( + "Deployment {} in resource group {} did not reach RUNNING status after {} retries", + deploymentId, + resourceGroupId, + config.maxRetries()); + throw new ServiceException( + ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); + } + + private AiDeploymentList queryDeploymentsUntilReady( + String resourceGroupId, ModelDeploymentSpec spec) { + Retry retry = resolver.getRetry(); + return Retry.decorateSupplier( + retry, + () -> + 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 0549daa..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,10 +4,14 @@ 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.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; 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; @@ -15,14 +19,30 @@ abstract class AbstractCrudHandler implements EventHandler { - protected final AICoreServiceImpl service; + protected final AICoreConfig config; + protected final AICoreClients clients; + protected final DeploymentResolver resolver; - protected AbstractCrudHandler(AICoreServiceImpl service) { - this.service = service; + protected AbstractCrudHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; } - protected String resolveResourceGroup(Map keys) { - return service.resolveResourceGroupFromKeys(keys); + /** + * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference + * 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")) { + 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 resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** @@ -30,26 +50,34 @@ 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) { + if (isProviderUser(context) || !config.multiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant == null) { return; } - BckndResourceGroup rg = service.getResourceGroupApi().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 810068b..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 @@ -3,13 +3,16 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; 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.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.services.EventContext; +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; @@ -21,29 +24,22 @@ public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); - public ActionHandler(AICoreServiceImpl service) { - super(service); + public ActionHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) - public void onStop(EventContext context) { - Map keys = asMap(context.get("keys")); + @On(entity = Deployments_.CDS_NAME) + public void onStop(DeploymentsStopContext context) { + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); + 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); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); 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 bafdbff..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 @@ -3,15 +3,17 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ConfigurationApi; 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.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_; 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; @@ -21,7 +23,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; @@ -36,14 +37,12 @@ public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); - private final ConfigurationApi configurationApi; - - public ConfigurationHandler(AICoreServiceImpl service) { - super(service); - this.configurationApi = service.getConfigurationApi(); + public ConfigurationHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -51,8 +50,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, @@ -61,12 +60,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()); @@ -74,13 +75,13 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + @On(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() @@ -97,7 +98,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 0b3df10..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 @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; 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 +10,11 @@ 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.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; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; @@ -25,7 +27,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; @@ -40,14 +41,12 @@ public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); - private final DeploymentApi deploymentApi; - - public DeploymentHandler(AICoreServiceImpl service) { - super(service); - this.deploymentApi = service.getDeploymentApi(); + public DeploymentHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -55,28 +54,28 @@ 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) { - 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))); } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + @On(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 = @@ -86,7 +85,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); @@ -95,7 +94,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + @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"); @@ -112,8 +111,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(); @@ -124,12 +123,12 @@ 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)); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -137,10 +136,10 @@ 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); + 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..f5155a2 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -0,0 +1,90 @@ +/* + * © 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.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 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 ServiceException( + ErrorStatuses.NOT_IMPLEMENTED, + "Inference client is not available without an AI Core service binding"); + } + + /** 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 b0e7094..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 @@ -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,11 @@ 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.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.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,12 +22,12 @@ 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; 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; @@ -39,14 +41,12 @@ public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); - private final ResourceGroupApi resourceGroupApi; - - public ResourceGroupHandler(AICoreServiceImpl service) { - super(service); - this.resourceGroupApi = service.getResourceGroupApi(); + public ResourceGroupHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -61,18 +61,18 @@ public void onRead(CdsReadEventContext context) { } if (resourceGroupId != null) { - BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); - ensureOwnedByCurrentTenant(rg); + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); + 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); + clients.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(entity = ResourceGroups_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -90,13 +90,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())); } @@ -108,22 +107,22 @@ 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); } context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + @On(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, clients.resourceGroupApi().get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -134,51 +133,51 @@ 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))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + @On(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, clients.resourceGroupApi().get(resourceGroupId)); - resourceGroupApi.delete(resourceGroupId); + clients.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); } if (keys.containsKey(ResourceGroups.TENANT_ID)) { - return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + return resolver.resolveResourceGroup((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.resourceGroup(); + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** * 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); + return List.of(AICoreConfig.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 (config.multiTenancyEnabled() && !isProviderUser(context)) { + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant != null) { - return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant); + return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + currentTenant); } } return null; @@ -189,14 +188,14 @@ 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) { + if (isProviderUser(context)) { return; } - if (!service.isMultiTenancyEnabled()) { + if (!config.multiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant == null) { return; } @@ -204,7 +203,7 @@ private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { && rg.getLabels().stream() .anyMatch( l -> - AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) && currentTenant.equals(l.getValue()))) { return; } @@ -234,7 +233,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()); } } 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 4dc62c2..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 @@ -25,11 +25,13 @@ 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; 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; @@ -37,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 { @@ -52,24 +54,39 @@ 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); } - /** 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(); + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + configurer.cdsModel("edmx/csn.json"); + CdsRuntime runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(svc); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.complete(); + return svc; } @BeforeEach @@ -77,23 +94,12 @@ void setUp() { deploymentApi = mock(DeploymentApi.class); configurationApi = mock(ConfigurationApi.class); resourceGroupApi = mock(ResourceGroupApi.class); - - CdsRuntime runtime = createTestRuntime(); - - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - false, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + service = createService(false); } @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); @@ -108,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); @@ -127,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)).isSameAs(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); @@ -161,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()); } @@ -193,17 +197,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"); @@ -216,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())) @@ -241,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..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 @@ -4,12 +4,11 @@ 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.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; @@ -17,12 +16,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 +31,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 +39,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 +47,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 +59,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 +67,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 +75,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 +92,111 @@ 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); + ResourceGroupApi resourceGroupApi = mock(ResourceGroupApi.class); + return new DeploymentResolver(CONFIG, deploymentApi, resourceGroupApi); } - 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..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 @@ -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, resourceGroupApi); 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 03e0579..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 @@ -5,65 +5,157 @@ 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.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.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.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; +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 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(); + + 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, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.complete(); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(configurationApi, resourceGroupApi); + } @Test - void onRead_nullResources_returnsEmptyListWithoutNpe() { - when(service.getConfigurationApi()).thenReturn(configurationApi); - when(context.getCqn()).thenReturn(select); - when(context.getModel()).thenReturn(model); - 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(service); - 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 cc26a3c..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 @@ -7,187 +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.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.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.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.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 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(); + + 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, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); + } @BeforeEach - void setup() { - when(service.getDeploymentApi()).thenReturn(deploymentApi); - cut = new DeploymentHandler(service); + 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); - 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); - 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(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"); + 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 14a828b..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 @@ -4,454 +4,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.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.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.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.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.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(); + + 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, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); + } @BeforeEach - void setUp() { - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - handler = new ResourceGroupHandler(service); + 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)); + 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"); + } - handler.onCreate(createContext, entries); + @Test + 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-1"); - assertThat(request.getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getResourceGroupId()).isEqualTo("rg-new"); } @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); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); - assertThat(request.getLabels()) + void onCreate_withTenantId_setsTenantLabel() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.resourceGroups") + .entry( + Map.of( + "resourceGroupId", "rg-tenant", + "tenantId", "tenant-a")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple("env", "prod"), tuple("team", "ai")); + .containsExactly(tuple(AICoreConfig.TENANT_LABEL_KEY, "tenant-a")); } @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); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - // Tenant label first, then user-supplied labels — and tenant label is NOT lost. - 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-b"), tuple("env", "prod")); + .containsExactly(tuple("env", "staging")); } @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()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); + 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 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")); + 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(); + + 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, mtResourceGroupApi); + + mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); + configurer.service(mtService); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); } - @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"); + @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(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") + 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"))); + 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(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(); - } + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant"); } - - @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); - 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 d1171ec..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 @@ -5,150 +5,257 @@ 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.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; +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; + 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(AICoreServiceImpl service) { - super(service); - } + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); - void callEnsureResourceGroupAccessible(String resourceGroupId) { - ensureResourceGroupAccessible(resourceGroupId); - } - } + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); - private TestableHandler handler; + 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, resourceGroupApi); - @BeforeEach - void setUp() { - handler = new TestableHandler(service); + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); } - // ── ensureResourceGroupAccessible ────────────────────────────────────────── - - @Test - void providerUser_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(true); - - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) - .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); + @BeforeEach + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi); } @Test - void singleTenancy_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(false); + void matchingTenant_allowsAccess() { + stubResourceGroupWithTenant("rg-a", "tenant-A"); + stubDeploymentQuery(); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("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 multiTenancy_nullTenant_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn(null); + void nonMatchingTenant_throws404() { + stubResourceGroupWithTenant("rg-b", "tenant-A"); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("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_matchingTenantLabel_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + void providerUser_bypassesTenantCheck() { + stubResourceGroupWithTenant("rg-c", "tenant-X"); + stubDeploymentQuery(); - 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")) + // 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(); } @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); + void nullTenantUser_bypassesTenantCheck() { + stubDeploymentQuery(); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) - .isInstanceOf(ServiceException.class) - .hasMessageContaining("not found"); + // 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_noLabels_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - + void noLabelsOnResourceGroup_throws404() { 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( + () -> + 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"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - + 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("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(AICoreConfig.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); + } } 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/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 } 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<>();