Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actions/integration-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ runs:
run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml
working-directory: integration-tests
shell: bash

- name: Cleanup AI Core test resource groups
if: always()
working-directory: integration-tests
shell: bash
Comment thread
Schmarvinius marked this conversation as resolved.
env:
RESOURCE_GROUP_PREFIX: itest-${{ github.run_id }}-${{ github.run_attempt }}-j${{ inputs.java-version }}
run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js
8 changes: 8 additions & 0 deletions .github/actions/scan-with-sonar/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ runs:
working-directory: integration-tests
shell: bash

- name: Cleanup AI Core test resource groups
Comment thread
Schmarvinius marked this conversation as resolved.
if: always()
working-directory: integration-tests
shell: bash
env:
RESOURCE_GROUP_PREFIX: sonar-${{ github.run_id }}-${{ github.run_attempt }}
run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js

- name: Generate aggregate coverage report
run: mvn verify -ntp -B -pl coverage-report -am -DskipTests
shell: bash
Expand Down
115 changes: 115 additions & 0 deletions .github/scripts/cleanup-resource-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Cleans up AI Core test resource groups.
*
* Required environment variables:
* RESOURCE_GROUP_PREFIX - The prefix identifying resource groups owned by this run
* (e.g. "itest-12345-1-j17" or "sonar-12345-1")
*
* Optional environment variables:
* STALE_PREFIXES - Comma-separated list of additional prefixes to clean up
* (defaults to "itest-rg-,cds-itest-")
*
* Credentials are resolved from VCAP_SERVICES (via cds bind) or AICORE_SERVICE_KEY.
*/
const https = require("https");

const DEFAULT_STALE_PREFIXES = ["itest-rg-", "cds-itest-"];

function getCredentials() {
const vcap = JSON.parse(process.env.VCAP_SERVICES || "{}");
return (
(vcap.aicore || vcap["ai-core"] || [{}])[0].credentials ||
JSON.parse(process.env.AICORE_SERVICE_KEY || "null")
);
}

function request(url, opts = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request(
{
hostname: u.hostname,
path: u.pathname + u.search,
method: opts.method || "GET",
headers: opts.headers || {},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode, body: data }));
}
);
req.on("error", reject);
if (opts.body) req.write(opts.body);
req.end();
});
}

async function getAccessToken(credentials) {
const tokenUrl = credentials.url + "/oauth/token";
const params = new URLSearchParams({ grant_type: "client_credentials" });
const authHeader =
"Basic " +
Buffer.from(credentials.clientid + ":" + credentials.clientsecret).toString(
"base64"
);
const res = await request(tokenUrl + "?" + params.toString(), {
headers: { Authorization: authHeader },
});
return JSON.parse(res.body).access_token;
}

async function deleteResourceGroups(apiUrl, headers, prefixes) {
const res = await request(apiUrl + "/v2/admin/resourceGroups", { headers });
const groups = JSON.parse(res.body).resources || [];
const toDelete = groups.filter(
(rg) =>
rg.resourceGroupId &&
prefixes.some((p) => rg.resourceGroupId.startsWith(p))
);

for (const rg of toDelete) {
const delRes = await request(
apiUrl + "/v2/admin/resourceGroups/" + rg.resourceGroupId,
{ method: "DELETE", headers }
);
console.log("Delete", rg.resourceGroupId, "->", delRes.status);
}

console.log("Cleaned up", toDelete.length, "resource groups");
}

async function main() {
const ownPrefix = process.env.RESOURCE_GROUP_PREFIX;
if (!ownPrefix) {
console.error("RESOURCE_GROUP_PREFIX environment variable is required");
process.exit(1);
}

const credentials = getCredentials();
if (!credentials) {
console.log("No AI Core credentials found, skipping cleanup");
return;
}

const stalePrefixes = process.env.STALE_PREFIXES
? process.env.STALE_PREFIXES.split(",").map((s) => s.trim())
: DEFAULT_STALE_PREFIXES;

const prefixes = [ownPrefix, ...stalePrefixes];

const apiUrl = credentials.serviceurls.AI_API_URL;
const token = await getAccessToken(credentials);
const headers = {
Authorization: "Bearer " + token,
"AI-Resource-Group": "default",
};

console.log("Cleaning resource groups matching prefixes:", prefixes);
await deleteResourceGroups(apiUrl, headers, prefixes);
}

main().catch((e) => {
console.error(e.message);
process.exit(0);
});
61 changes: 0 additions & 61 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,67 +65,6 @@ jobs:
java-version: ${{ matrix.java-version }}
maven-version: ${{ env.MAVEN_VERSION }}

integration-tests-cleanup:
name: Cleanup Integration Test Resources
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [integration-tests, sonarqube-scan]
if: always()
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Bind CF Services
uses: ./.github/actions/cf-bind
with:
cf-api: ${{ secrets.CF_API_AWS }}
cf-username: ${{ secrets.CF_USERNAME }}
cf-password: ${{ secrets.CF_PASSWORD }}
cf-org: ${{ secrets.CF_ORG_AWS }}
cf-space: ${{ secrets.CF_SPACE_AWS }}

- name: Cleanup resource groups
working-directory: integration-tests
shell: bash
run: |
cds bind --exec -- node -e "
const https = require('https');
const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}');
const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null');
if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); }
const tokenUrl = key.url + '/oauth/token';
const apiUrl = key.serviceurls.AI_API_URL;
function fetch(url, opts) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => {
let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data}));
});
req.on('error',reject);
if(opts.body) req.write(opts.body);
req.end();
});
}
(async () => {
const tokenParams = new URLSearchParams({grant_type:'client_credentials'});
const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64');
const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}});
const token = JSON.parse(tokenRes.body).access_token;
const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'};
const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}'];
const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers});
const groups = JSON.parse(rgRes.body).resources || [];
const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p)));
for (const rg of toDelete) {
const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers});
console.log('Delete', rg.resourceGroupId, '->', res.status);
}
console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}');
})().catch(e => { console.error(e.message); process.exit(1); });
"

local-mtx-tests:
name: Local MTX Tests (Java ${{ matrix.java-version }})
runs-on: ubuntu-latest
Expand Down
20 changes: 11 additions & 9 deletions cds-feature-ai-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ Without a binding the plugin registers a mock implementation.

## Configuration

All configuration is under the `cds.requires.AICore` namespace in `application.yaml`:
All configuration is under the `cds.ai.core` namespace in `application.yaml`:

```yaml
cds:
requires:
AICore:
ai:
core:
resourceGroup: default # Resource group for single-tenant mode
resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups
multiTenancy: false # Enable per-tenant resource groups
maxRetries: 10 # Max retry attempts for transient AI Core errors
initialDelayMs: 300 # Initial backoff delay (ms)
```

Multi-tenancy is auto-detected from CAP Java's standard `cds.multiTenancy.sidecar.url` setting
and the presence of a `DeploymentService`. No additional configuration flag is required.

## CDS Service: `AICore`

The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs as CDS entities:
Expand All @@ -61,19 +63,19 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs
| `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking |
| `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables |

### Functions & Actions
### Programmatic API

```java
// Get the resource group ID for a CDS tenant
String rgId = aiCoreService.resourceGroupForTenant(tenantId);
// Get the resource group for the current tenant
String rgId = aiCoreService.resourceGroup();

// Get (or auto-create) a deployment ID for a model spec in the given resource group
String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1());
```

## Multi-Tenancy

When `cds.requires.AICore.multiTenancy=true`:
When multi-tenancy is active (detected via `cds.multiTenancy.sidecar.url`):

1. **Subscribe** - Creates resource group `{prefix}{tenantId}` with label `ext.ai.sap.com/CDS_TENANT_ID`
2. **Unsubscribe** - Deletes the tenant's resource group
Expand All @@ -92,7 +94,7 @@ AICoreService aiCore = runtime.getServiceCatalog()
Result rgs = aiCore.run(Select.from("AICore.resourceGroups"));

// Resolve a deployment and obtain a configured ApiClient for it
String resourceGroupId = aiCore.resourceGroupForTenant(tenantId);
String resourceGroupId = aiCore.resourceGroup();
String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1());
ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId);
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import com.sap.cds.services.cds.CqnService;
import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
import io.github.resilience4j.retry.Retry;

/**
* CAP service contract for SAP AI Core integration.
Expand All @@ -15,20 +14,16 @@
* provides programmatic helpers to:
*
* <ul>
* <li>Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}),
* creating it on-demand when multi-tenancy is enabled.
* <li>Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating
* it on-demand when multi-tenancy is enabled.
* <li>Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link
* #deploymentId(String, ModelDeploymentSpec)}).
* <li>Build an {@link ApiClient} preconfigured for inference against a specific deployment
* ({@link #inferenceClient(String, String)}).
* <li>Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want
* consistent transient-error handling.
* </ul>
*
* <p>Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl}
* (when an SAP AI Core service binding is detected) and {@link
* com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local
* development).
* <p>The implementation is tenant-aware: it reads the current tenant from the {@code
* RequestContext}. Callers do not need to pass tenant identifiers explicitly.
*/
public interface AICoreService extends CqnService {

Expand All @@ -45,16 +40,15 @@ public interface AICoreService extends CqnService {
String CONFIGURATIONS = "AICore.configurations";

/**
* Returns the AI Core resource group ID associated with the given CDS tenant.
* Returns the AI Core resource group ID associated with the current tenant.
*
* <p>When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is
* returned for every tenant. When enabled, the resource group is looked up by the {@code
* ext.ai.sap.com/CDS_TENANT_ID} label and created on first call if it does not exist.
* <p>When multi-tenancy is disabled the configured default resource group is returned. When
* enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and
* created on first call if it does not exist.
*
* @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled
* @return the AI Core resource group ID
* @return the AI Core resource group ID for the current tenant
*/
String resourceGroupForTenant(String tenantId);
String resourceGroup();

/**
* Returns the deployment ID for the given model spec inside the given resource group.
Expand All @@ -80,13 +74,4 @@ public interface AICoreService extends CqnService {
* @return a configured {@link ApiClient} pointing at the deployment's inference endpoint
*/
ApiClient inferenceClient(String resourceGroupId, String deploymentId);

/** Returns whether multi-tenancy is enabled for this service. */
boolean isMultiTenancyEnabled();

/**
* Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so
* downstream inference clients can reuse the same backoff policy.
*/
Retry getRetry();
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import com.sap.cds.feature.aicore.core.handler.DeploymentHandler;
import com.sap.cds.feature.aicore.core.handler.MockEntityHandler;
import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler;
import com.sap.cds.services.environment.CdsProperties;
import com.sap.cds.services.mt.DeploymentService;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfiguration;
import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
Expand Down Expand Up @@ -50,16 +52,27 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) {
return envKey != null && !envKey.isBlank();
}

/**
* Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url}
* property or the presence of a {@link DeploymentService} in the service catalog. This aligns
* with the standard CAP Java convention — no custom property flag is needed.
*/
private static boolean detectMultiTenancy(CdsRuntime runtime) {
CdsProperties props = runtime.getEnvironment().getCdsProperties();
String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl();
if (sidecarUrl != null && !sidecarUrl.isBlank()) {
return true;
}
return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null;
}

@Override
public void services(CdsRuntimeConfigurer configurer) {
CdsRuntime runtime = configurer.getCdsRuntime();

boolean hasBinding = hasAICoreBinding(runtime);

boolean multiTenancyEnabled =
runtime
.getEnvironment()
.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false);
boolean multiTenancyEnabled = detectMultiTenancy(runtime);

if (hasBinding) {
AICoreServiceImpl service =
Expand All @@ -75,7 +88,7 @@ public void services(CdsRuntimeConfigurer configurer) {
logger.info("Registered AICoreService backed by AI Core binding.");
} else {
MockAICoreServiceImpl mockService =
new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime);
new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled);
configurer.service(mockService);
logger.info("Registered MockAICoreService (no AI Core binding found).");
}
Expand Down
Loading
Loading