sap-ai-dev-toolkit 0.9.9922 → 0.9.9923
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/sap-btp-infrastructure-admin.agent.md +23 -0
- package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
- package/.github/agents/sap-datasphere-developer.agent.md +22 -0
- package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
- package/.github/agents/sap-process-automation-developer.agent.md +50 -0
- package/.github/skills/btp-platform-administration/SKILL.md +53 -0
- package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
- package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
- package/.github/skills/datasphere-administration/SKILL.md +40 -0
- package/.github/skills/datasphere-development/SKILL.md +55 -0
- package/.github/skills/process-automation-development/SKILL.md +113 -0
- package/README.md +152 -8
- package/inventory.md +60 -0
- package/package.json +8 -2
- package/src/btp-cli.mjs +199 -0
- package/src/btp-server.mjs +125 -0
- package/src/btp-tools.mjs +776 -0
- package/src/cloud-alm-client.mjs +44 -0
- package/src/cloud-alm-config.mjs +92 -0
- package/src/cloud-alm-credentials.mjs +29 -0
- package/src/cloud-alm-data.mjs +129 -0
- package/src/cloud-alm-server.mjs +115 -0
- package/src/cloud-alm-tools.mjs +682 -0
- package/src/datasphere-auth.mjs +279 -0
- package/src/datasphere-cli.mjs +220 -0
- package/src/datasphere-command.mjs +236 -0
- package/src/datasphere-config.mjs +154 -0
- package/src/datasphere-credentials.mjs +37 -0
- package/src/datasphere-csn.mjs +73 -0
- package/src/datasphere-discovery.mjs +151 -0
- package/src/datasphere-server.mjs +249 -0
- package/src/datasphere-tools.mjs +809 -0
- package/src/hana-database.mjs +3 -3
- package/src/hana-inspector.mjs +2 -2
- package/src/harnesses.mjs +31 -1
- package/src/install-user-assets.mjs +1 -1
- package/src/integration-common.mjs +7 -7
- package/src/integration-config.mjs +5 -0
- package/src/integration-credentials.mjs +25 -16
- package/src/integration-http.mjs +24 -7
- package/src/launcher.mjs +56 -4
- package/src/mcp-config.mjs +37 -1
- package/src/process-automation-automation-tools.mjs +125 -0
- package/src/process-automation-client.mjs +36 -0
- package/src/process-automation-common.mjs +69 -0
- package/src/process-automation-config.mjs +142 -0
- package/src/process-automation-credentials.mjs +37 -0
- package/src/process-automation-inbox-tools.mjs +246 -0
- package/src/process-automation-server.mjs +139 -0
- package/src/process-automation-tools.mjs +727 -0
- package/src/process-automation-visibility-tools.mjs +182 -0
- package/src/setup.mjs +623 -11
- package/tools.md +138 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
// MCP tools for SAP BTP core platform administration over the user's own btp
|
|
2
|
+
// CLI session: account hierarchy, entitlements and quotas, Service Manager
|
|
3
|
+
// instances and service keys, destinations, trust and Cloud Connectors.
|
|
4
|
+
// Writes are limited to confirmation-gated instance/key creation; every result
|
|
5
|
+
// passes through secret redaction and service-key credentials stay redacted.
|
|
6
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import {
|
|
11
|
+
CREATE_ANNOTATIONS,
|
|
12
|
+
READ_ONLY_ANNOTATIONS,
|
|
13
|
+
confirmation,
|
|
14
|
+
pick,
|
|
15
|
+
redactSecrets,
|
|
16
|
+
requireConfirmation,
|
|
17
|
+
resultOf
|
|
18
|
+
} from './integration-common.mjs';
|
|
19
|
+
import { IntegrationError, createServiceClient } from './integration-http.mjs';
|
|
20
|
+
import { finalizeClientCredentials } from './integration-config.mjs';
|
|
21
|
+
import { redactText } from './redact.mjs';
|
|
22
|
+
import { parseCliJson } from './btp-cli.mjs';
|
|
23
|
+
|
|
24
|
+
export const BTP_TOOL_NAMES = Object.freeze([
|
|
25
|
+
'btp_connection_info',
|
|
26
|
+
'btp_get_account_hierarchy',
|
|
27
|
+
'btp_list_entitlements',
|
|
28
|
+
'btp_check_quota',
|
|
29
|
+
'btp_list_service_instances',
|
|
30
|
+
'btp_get_service_instance',
|
|
31
|
+
'btp_list_service_keys',
|
|
32
|
+
'btp_get_service_key',
|
|
33
|
+
'btp_list_destinations',
|
|
34
|
+
'btp_get_destination',
|
|
35
|
+
'btp_get_trust',
|
|
36
|
+
'btp_list_cloud_connectors',
|
|
37
|
+
'btp_check_destination',
|
|
38
|
+
'btp_create_service_instance',
|
|
39
|
+
'btp_create_service_key'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const CATALOG_TTL_MS = 10 * 60_000;
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
// Default ("SAP BTP"/other) catalog first, then CF-only and Kyma-only offerings.
|
|
45
|
+
const ENVIRONMENTS = [undefined, 'cloudfoundry', 'kubernetes'];
|
|
46
|
+
|
|
47
|
+
const guid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, 'expected a GUID (subaccounts, directories, instances, keys)');
|
|
48
|
+
const catalogName = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/, 'expected a technical service or plan name, e.g. hana-cloud or api_access');
|
|
49
|
+
const newName = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/, 'use letters, digits, ".", "_" or "-" (max 100)');
|
|
50
|
+
const lookupName = z.string().min(1).max(255).refine(value => !value.startsWith('-') && !/[\r\n']/.test(value), 'invalid name');
|
|
51
|
+
const destinationName = z.string().regex(/^[A-Za-z0-9_-]{1,200}$/, 'expected a destination name');
|
|
52
|
+
const payload = z.record(z.string(), z.unknown());
|
|
53
|
+
const top = z.number().int().min(1).max(500).default(200);
|
|
54
|
+
const nameContains = z.string().min(1).max(100).optional();
|
|
55
|
+
|
|
56
|
+
const SUBACCOUNT_FIELDS = ['guid', 'displayName', 'subdomain', 'region', 'state', 'parentGUID', 'parentType', 'usedForProduction', 'betaEnabled'];
|
|
57
|
+
const DIRECTORY_FIELDS = ['guid', 'displayName', 'parentGUID', 'directoryFeatures', 'entityState'];
|
|
58
|
+
const ASSIGNMENT_FIELDS = ['entityId', 'entityType', 'amount', 'requestedAmount', 'unlimitedAmountAssigned', 'parentId', 'parentType', 'parentAmount', 'parentRemainingAmount', 'entityState', 'stateMessage', 'autoAssigned'];
|
|
59
|
+
const TRUST_FIELDS = ['originKey', 'name', 'typeOfTrust', 'protocol', 'status', 'identityProvider', 'domain', 'availableForUserLogon', 'createShadowUsersDuringLogon', 'readOnly'];
|
|
60
|
+
|
|
61
|
+
const REQUIRED_AUTH_PROPERTIES = Object.freeze({
|
|
62
|
+
BasicAuthentication: ['User', 'Password'],
|
|
63
|
+
OAuth2ClientCredentials: ['clientId', 'clientSecret', 'tokenServiceURL'],
|
|
64
|
+
OAuth2JWTBearer: ['clientId', 'clientSecret', 'tokenServiceURL'],
|
|
65
|
+
OAuth2UserTokenExchange: ['clientId', 'clientSecret', 'tokenServiceURL'],
|
|
66
|
+
OAuth2Password: ['clientId', 'clientSecret', 'tokenServiceURL', 'User', 'Password'],
|
|
67
|
+
OAuth2SAMLBearerAssertion: ['audience', 'authnContextClassRef', 'clientKey', 'tokenServiceURL'],
|
|
68
|
+
PrincipalPropagation: [],
|
|
69
|
+
NoAuthentication: []
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const PRINCIPAL_PROPAGATION_MANUAL = 'System certificate, principal-propagation CA certificate, synchronized IdP trust and the X.509 principal type of the system mapping are only visible in the Cloud Connector administration; verify there and the backend certificate mapping (e.g. CERTRULE).';
|
|
73
|
+
|
|
74
|
+
function rows(value) {
|
|
75
|
+
if (Array.isArray(value)) return value;
|
|
76
|
+
const nested = value?.value ?? value?.items;
|
|
77
|
+
return Array.isArray(nested) ? nested : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseMaybeJson(value) {
|
|
81
|
+
if (typeof value !== 'string') return value;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(value);
|
|
84
|
+
} catch {
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function msFrom(value) {
|
|
90
|
+
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
91
|
+
if (typeof number !== 'number' || !Number.isFinite(number)) return null;
|
|
92
|
+
return number < 1e12 ? number * 1000 : number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isoFromMs(value) {
|
|
96
|
+
const ms = msFrom(value);
|
|
97
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const byName = (left, right) => String(left.name ?? '').localeCompare(String(right.name ?? ''));
|
|
101
|
+
const contains = (value, needle) => String(value ?? '').toLowerCase().includes(needle.toLowerCase());
|
|
102
|
+
const bindingFilter = id => `service_instance_id eq '${id}'`;
|
|
103
|
+
|
|
104
|
+
/** Redact credential-shaped strings and the key/passwd fields redactSecrets does not cover. */
|
|
105
|
+
function redactCredentials(value) {
|
|
106
|
+
if (typeof value === 'string') return redactText(value);
|
|
107
|
+
if (Array.isArray(value)) return value.map(redactCredentials);
|
|
108
|
+
if (!value || typeof value !== 'object') return value;
|
|
109
|
+
return Object.fromEntries(Object.entries(value).map(([name, entry]) => [
|
|
110
|
+
name,
|
|
111
|
+
/^key$|passwd/i.test(name) && entry !== null && entry !== '' && typeof entry !== 'object'
|
|
112
|
+
? '[redacted]'
|
|
113
|
+
: redactCredentials(entry)
|
|
114
|
+
]));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function instanceContext(instance) {
|
|
118
|
+
const context = parseMaybeJson(instance?.context);
|
|
119
|
+
return context && typeof context === 'object' ? context : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function instanceRow(instance, planInfo) {
|
|
123
|
+
const context = instanceContext(instance);
|
|
124
|
+
return {
|
|
125
|
+
id: instance?.id,
|
|
126
|
+
name: instance?.name,
|
|
127
|
+
offering: planInfo?.offering ?? null,
|
|
128
|
+
plan: planInfo?.plan ?? null,
|
|
129
|
+
service_plan_id: instance?.service_plan_id,
|
|
130
|
+
platform: context.platform ?? instance?.platform_id ?? null,
|
|
131
|
+
org: context.organization_name ?? null,
|
|
132
|
+
space: context.space_name ?? null,
|
|
133
|
+
ready: instance?.ready,
|
|
134
|
+
usable: instance?.usable,
|
|
135
|
+
shared: instance?.shared,
|
|
136
|
+
created_at: instance?.created_at,
|
|
137
|
+
updated_at: instance?.updated_at,
|
|
138
|
+
labels: instance?.labels
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function keyRow(binding, instanceName) {
|
|
143
|
+
const context = instanceContext(binding);
|
|
144
|
+
return {
|
|
145
|
+
id: binding?.id,
|
|
146
|
+
name: binding?.name,
|
|
147
|
+
service_instance_id: binding?.service_instance_id,
|
|
148
|
+
instance_name: instanceName ?? null,
|
|
149
|
+
ready: binding?.ready,
|
|
150
|
+
platform: context.platform ?? null,
|
|
151
|
+
created_at: binding?.created_at,
|
|
152
|
+
updated_at: binding?.updated_at,
|
|
153
|
+
labels: binding?.labels
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const lastOperation = value => (value?.last_operation ? pick(value.last_operation, ['type', 'state', 'description', 'updated_at']) : null);
|
|
158
|
+
|
|
159
|
+
/** Parameters go to a private temp file, never argv (they may hold passwords). */
|
|
160
|
+
async function withTempJson(value, fn) {
|
|
161
|
+
if (value === undefined) return fn(undefined);
|
|
162
|
+
const dir = await mkdtemp(join(tmpdir(), 'sap-ai-btp-'));
|
|
163
|
+
try {
|
|
164
|
+
const path = join(dir, 'parameters.json');
|
|
165
|
+
await writeFile(path, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
166
|
+
return await fn(path);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function virtualHost(url) {
|
|
173
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
174
|
+
return `${url.hostname}:${port}`.toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Register all SAP BTP tools; returns the registered names. */
|
|
178
|
+
export function registerBtpTools(server, { cli, summary, verify, env = process.env, now = Date.now } = {}) {
|
|
179
|
+
const registeredNames = [];
|
|
180
|
+
const register = (name, title, description, inputSchema, annotations, handler) => {
|
|
181
|
+
registeredNames.push(name);
|
|
182
|
+
server.registerTool(name, { title, description, inputSchema, annotations }, async args => resultOf(
|
|
183
|
+
async () => redactSecrets(await handler(args)),
|
|
184
|
+
{ product: 'SAP BTP', fallback: 'SAP BTP request failed. Check the btp CLI login (btp login --sso), the subaccount id and your role collections.' }
|
|
185
|
+
));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------ catalog
|
|
189
|
+
|
|
190
|
+
const catalogCache = new Map();
|
|
191
|
+
const catalog = (subaccount, environment) => {
|
|
192
|
+
const cacheKey = `${subaccount}|${environment ?? 'sapbtp'}`;
|
|
193
|
+
const cached = catalogCache.get(cacheKey);
|
|
194
|
+
if (cached && now() - cached.at < CATALOG_TTL_MS) return cached.promise;
|
|
195
|
+
const promise = (async () => {
|
|
196
|
+
const offerings = rows(await cli.json(['list', 'services/offering'], { subaccount, environment }));
|
|
197
|
+
const plans = rows(await cli.json(['list', 'services/plan'], { subaccount, environment }));
|
|
198
|
+
const offeringNames = new Map(offerings.map(offering => [offering.id, offering.name]));
|
|
199
|
+
return new Map(plans.map(plan => [plan.id, {
|
|
200
|
+
id: plan.id,
|
|
201
|
+
plan: plan.name,
|
|
202
|
+
offering: offeringNames.get(plan.service_offering_id) ?? null,
|
|
203
|
+
offering_id: plan.service_offering_id
|
|
204
|
+
}]));
|
|
205
|
+
})();
|
|
206
|
+
catalogCache.set(cacheKey, { at: now(), promise });
|
|
207
|
+
promise.catch(() => {
|
|
208
|
+
if (catalogCache.get(cacheKey)?.promise === promise) catalogCache.delete(cacheKey);
|
|
209
|
+
});
|
|
210
|
+
return promise;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const describePlans = async (subaccount, planIds) => {
|
|
214
|
+
const wanted = new Set([...planIds].filter(Boolean));
|
|
215
|
+
const found = new Map();
|
|
216
|
+
for (const environment of ENVIRONMENTS) {
|
|
217
|
+
if ([...wanted].every(id => found.has(id))) break;
|
|
218
|
+
const plans = await catalog(subaccount, environment);
|
|
219
|
+
for (const id of wanted) if (!found.has(id) && plans.has(id)) found.set(id, plans.get(id));
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const planIdsFor = async (subaccount, offering, plan) => {
|
|
225
|
+
for (const environment of ENVIRONMENTS) {
|
|
226
|
+
const plans = await catalog(subaccount, environment);
|
|
227
|
+
const ids = new Set([...plans.values()].filter(info => info.offering === offering && info.plan === plan).map(info => info.id));
|
|
228
|
+
if (ids.size) return ids;
|
|
229
|
+
}
|
|
230
|
+
return new Set();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const listInstances = async subaccount => rows(await cli.json(['list', 'services/instance'], { subaccount }));
|
|
234
|
+
const getInstance = (subaccount, { id, name }) => cli.json(['get', 'services/instance'], id ? { subaccount, id } : { subaccount, name });
|
|
235
|
+
const getBinding = (subaccount, { id, name }) => cli.json(['get', 'services/binding'], id ? { subaccount, id } : { subaccount, name });
|
|
236
|
+
const bindingsOf = async (subaccount, instanceId) => rows(await cli.json(['list', 'services/binding'], { subaccount, 'fields-filter': bindingFilter(instanceId) }))
|
|
237
|
+
.filter(binding => binding.service_instance_id === instanceId);
|
|
238
|
+
|
|
239
|
+
const describeInstance = async (subaccount, instance) => {
|
|
240
|
+
const plans = await describePlans(subaccount, [instance?.service_plan_id]);
|
|
241
|
+
return {
|
|
242
|
+
...instanceRow(instance, plans.get(instance?.service_plan_id)),
|
|
243
|
+
dashboard_url: instance?.dashboard_url,
|
|
244
|
+
last_operation: lastOperation(instance)
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const keyResult = (binding, subaccount) => ({
|
|
249
|
+
key: { ...keyRow(binding), last_operation: lastOperation(binding) },
|
|
250
|
+
credentials: redactCredentials(binding?.credentials ?? {}),
|
|
251
|
+
credentials_note: `Secrets are redacted. Fetch the full key in your own terminal: btp get services/binding --name ${binding?.name} --subaccount ${subaccount}`
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ------------------------------------------------------------ quota
|
|
255
|
+
|
|
256
|
+
const checkQuota = async ({ subaccount, service, plan, amount }) => {
|
|
257
|
+
const planIds = await planIdsFor(subaccount, service, plan);
|
|
258
|
+
if (!planIds.size) {
|
|
259
|
+
return {
|
|
260
|
+
subaccount, service, plan, requested: amount,
|
|
261
|
+
verdict: 'not_entitled',
|
|
262
|
+
entitlement: null,
|
|
263
|
+
reasons: [`${service}/${plan} is not in the Service Manager catalog of subaccount ${subaccount}: not entitled, or not offered in this region/environment`]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const entitlements = await cli.json(['list', 'accounts/entitlement'], { subaccount });
|
|
267
|
+
const plans = rows(entitlements?.assignedServices)
|
|
268
|
+
.filter(entry => entry.name === service)
|
|
269
|
+
.flatMap(entry => rows(entry.servicePlans))
|
|
270
|
+
.filter(entry => entry.name === plan);
|
|
271
|
+
let planEntry = plans[0];
|
|
272
|
+
let assignment;
|
|
273
|
+
for (const candidate of plans) {
|
|
274
|
+
const row = rows(candidate.assignmentInfo).find(info => info.entityId === subaccount);
|
|
275
|
+
if (row) {
|
|
276
|
+
planEntry = candidate;
|
|
277
|
+
assignment = row;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const category = planEntry?.category ?? null;
|
|
282
|
+
const result = {
|
|
283
|
+
subaccount, service, plan, requested: amount,
|
|
284
|
+
verdict: 'available',
|
|
285
|
+
entitlement: assignment ? {
|
|
286
|
+
category,
|
|
287
|
+
assigned_amount: assignment.amount,
|
|
288
|
+
unlimited: assignment.unlimitedAmountAssigned === true,
|
|
289
|
+
entity_state: assignment.entityState,
|
|
290
|
+
state_message: assignment.stateMessage
|
|
291
|
+
} : null
|
|
292
|
+
};
|
|
293
|
+
if (assignment?.parentId) result.parent = { type: assignment.parentType, id: assignment.parentId, remaining: assignment.parentRemainingAmount };
|
|
294
|
+
const reasons = [];
|
|
295
|
+
if (assignment?.entityState && assignment.entityState !== 'OK') {
|
|
296
|
+
result.verdict = 'assignment_not_ready';
|
|
297
|
+
reasons.push(`entitlement assignment is ${assignment.entityState}: ${assignment.stateMessage ?? ''}`.trim());
|
|
298
|
+
} else if (!assignment || assignment.unlimitedAmountAssigned === true || category !== 'SERVICE') {
|
|
299
|
+
reasons.push(`no numeric instance quota applies (${category ?? 'always-available plan'})`);
|
|
300
|
+
} else {
|
|
301
|
+
const instances = (await listInstances(subaccount)).filter(instance => planIds.has(instance.service_plan_id));
|
|
302
|
+
const assigned = Number(assignment.amount ?? 0);
|
|
303
|
+
const remaining = assigned - instances.length;
|
|
304
|
+
result.usage = { basis: 'service instances of this plan in the subaccount', instances: instances.length, names: instances.slice(0, 20).map(instance => instance.name) };
|
|
305
|
+
result.remaining = remaining;
|
|
306
|
+
if (remaining >= amount) {
|
|
307
|
+
reasons.push(`${instances.length} of ${assigned} instances exist; ${remaining} remaining`);
|
|
308
|
+
} else {
|
|
309
|
+
result.verdict = 'insufficient_quota';
|
|
310
|
+
reasons.push(`${instances.length} of ${assigned} instances already exist`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { ...result, reasons };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// ------------------------------------------------------------ connectivity
|
|
317
|
+
|
|
318
|
+
const connectivityApi = async (subaccount, instanceName) => {
|
|
319
|
+
const ids = await planIdsFor(subaccount, 'connectivity', 'api_access');
|
|
320
|
+
const instances = (ids.size ? await listInstances(subaccount) : [])
|
|
321
|
+
.filter(instance => ids.has(instance.service_plan_id) && (!instanceName || instance.name === instanceName))
|
|
322
|
+
.sort(byName);
|
|
323
|
+
if (!instances.length) {
|
|
324
|
+
throw new IntegrationError(`No Connectivity service instance with plan api_access in subaccount ${subaccount}${instanceName ? ` named ${instanceName}` : ''}. Create one with btp_create_service_instance (offering connectivity, plan api_access) and a service key with btp_create_service_key, then retry.`);
|
|
325
|
+
}
|
|
326
|
+
const instance = instances[0];
|
|
327
|
+
const keys = (await bindingsOf(subaccount, instance.id)).filter(binding => binding.ready !== false).sort(byName);
|
|
328
|
+
if (!keys.length) throw new IntegrationError(`Connectivity instance ${instance.name} has no service key. Create one with btp_create_service_key, then retry.`);
|
|
329
|
+
const key = await getBinding(subaccount, { id: keys[0].id });
|
|
330
|
+
const keyName = key?.name ?? keys[0].name;
|
|
331
|
+
const credentials = key?.credentials;
|
|
332
|
+
if (!credentials || typeof credentials !== 'object' || !Object.keys(credentials).length) {
|
|
333
|
+
throw new IntegrationError(`Service key ${keyName} of ${instance.name} returned no credentials; create a Service Manager service key with btp_create_service_key.`);
|
|
334
|
+
}
|
|
335
|
+
if (!credentials.clientsecret && (credentials.certificate || credentials.key)) {
|
|
336
|
+
throw new IntegrationError(`Service key ${keyName} is certificate-based; create a service key with client secret credentials.`);
|
|
337
|
+
}
|
|
338
|
+
const service = credentials.connectivity_service ?? {};
|
|
339
|
+
if (!service.url || !service.scc_info_path) {
|
|
340
|
+
throw new IntegrationError(`Service key ${keyName} has no connectivity_service.url/scc_info_path; use a key of a Connectivity api_access instance.`);
|
|
341
|
+
}
|
|
342
|
+
let config;
|
|
343
|
+
try {
|
|
344
|
+
config = finalizeClientCredentials('BTP_CONNECTIVITY', 'service-key', {
|
|
345
|
+
url: service.url,
|
|
346
|
+
tokenUrl: credentials.token_service_url,
|
|
347
|
+
clientId: credentials.clientid,
|
|
348
|
+
clientSecret: credentials.clientsecret
|
|
349
|
+
});
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new IntegrationError(`Service key ${keyName} is unusable: ${error.message}`);
|
|
352
|
+
}
|
|
353
|
+
const path = service.scc_info_path.startsWith('/') ? service.scc_info_path : `/${service.scc_info_path}`;
|
|
354
|
+
const client = createServiceClient({ label: 'BTP_CONNECTIVITY', url: config.url, tokenUrl: config.tokenUrl, clientId: config.clientId, clientSecret: config.clientSecret, env });
|
|
355
|
+
const answer = await client.request('GET', path);
|
|
356
|
+
const cloudConnectors = rows(answer).map(entry => {
|
|
357
|
+
const a = entry?.attributes ?? entry ?? {};
|
|
358
|
+
return {
|
|
359
|
+
location_id: a.location_id ?? a.locationId ?? null,
|
|
360
|
+
connected_by: a.connected_by,
|
|
361
|
+
connected_since: isoFromMs(a.connected_since),
|
|
362
|
+
agent_version: a.agent_version,
|
|
363
|
+
agent_type: a.agent_type,
|
|
364
|
+
security_status: parseMaybeJson(a.security_status),
|
|
365
|
+
exposed_systems: rows(parseMaybeJson(a.exposed_systems)).map(system => pick(system, ['protocol', 'host', 'type', 'status', 'description']))
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
return { instance: instance.name, service_key: keyName, cloud_connectors: cloudConnectors };
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const loadDestination = async (subaccount, name, serviceInstance) => {
|
|
372
|
+
const result = await cli.json(['get', 'connectivity/destination'], { subaccount, name, 'service-instance': serviceInstance });
|
|
373
|
+
return { configuration: result?.destinationConfiguration ?? {}, metadata: result?.systemMetadata ?? {} };
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const destinationTrust = async subaccount => {
|
|
377
|
+
try {
|
|
378
|
+
const trust = await cli.json(['get', 'connectivity/destination-trust'], { subaccount, active: true });
|
|
379
|
+
const expiry = msFrom(trust?.expirationTimestamp);
|
|
380
|
+
return {
|
|
381
|
+
name: trust?.Name,
|
|
382
|
+
active: trust?.active,
|
|
383
|
+
generated_on: trust?.generatedOn,
|
|
384
|
+
expires_at: isoFromMs(trust?.expirationTimestamp),
|
|
385
|
+
days_left: expiry === null ? null : Math.floor((expiry - now()) / DAY_MS),
|
|
386
|
+
certificate_base64: trust?.x509PublicKeyBase64
|
|
387
|
+
};
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error?.name !== 'IntegrationError') throw error;
|
|
390
|
+
return { error: error.message };
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const listTrust = async subaccount => rows(await cli.json(['list', 'security/trust'], { subaccount }));
|
|
395
|
+
|
|
396
|
+
// ------------------------------------------------------------ connection
|
|
397
|
+
|
|
398
|
+
register('btp_connection_info', 'BTP connection info',
|
|
399
|
+
'Report which btp CLI executable and configuration file the server uses and who acts (the user signed in with btp login --sso). verify: true runs btp --version and reads the global account to prove the session is valid.',
|
|
400
|
+
{ verify: z.boolean().default(false) },
|
|
401
|
+
READ_ONLY_ANNOTATIONS,
|
|
402
|
+
async ({ verify: online }) => {
|
|
403
|
+
const verification = online && verify ? await verify() : undefined;
|
|
404
|
+
return { ...summary, ...(verification ? { verification } : {}) };
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ------------------------------------------------------------ accounts and entitlements
|
|
408
|
+
|
|
409
|
+
register('btp_get_account_hierarchy', 'Get global account hierarchy',
|
|
410
|
+
'Read the global account with its directories and subaccounts (GUIDs, subdomains, regions, states). Use it to resolve the subaccount GUID every subaccount-scoped tool needs.',
|
|
411
|
+
{ name_contains: nameContains, top },
|
|
412
|
+
READ_ONLY_ANNOTATIONS,
|
|
413
|
+
async ({ name_contains: needle, top: limit }) => {
|
|
414
|
+
const account = await cli.json(['get', 'accounts/global-account'], { 'show-hierarchy': true });
|
|
415
|
+
const directories = [];
|
|
416
|
+
const subaccounts = [...rows(account?.subaccounts)];
|
|
417
|
+
const walk = nodes => {
|
|
418
|
+
for (const node of rows(nodes)) {
|
|
419
|
+
const features = rows(node.directoryFeatures);
|
|
420
|
+
directories.push({ ...pick(node, DIRECTORY_FIELDS), manages_entitlements: features.includes('ENTITLEMENTS') });
|
|
421
|
+
subaccounts.push(...rows(node.subaccounts));
|
|
422
|
+
walk(node.children);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
walk(account?.children);
|
|
426
|
+
const matching = subaccounts
|
|
427
|
+
.filter(subaccount => !needle || contains(subaccount.displayName, needle) || contains(subaccount.subdomain, needle))
|
|
428
|
+
.map(subaccount => pick(subaccount, SUBACCOUNT_FIELDS))
|
|
429
|
+
.sort((left, right) => String(left.displayName ?? '').localeCompare(String(right.displayName ?? '')));
|
|
430
|
+
return {
|
|
431
|
+
global_account: pick(account, ['guid', 'displayName', 'subdomain', 'licenseType', 'commercialModel']),
|
|
432
|
+
directories,
|
|
433
|
+
total_subaccounts: subaccounts.length,
|
|
434
|
+
returned_subaccounts: Math.min(matching.length, limit),
|
|
435
|
+
subaccounts: matching.slice(0, limit)
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
register('btp_list_entitlements', 'List entitlements',
|
|
440
|
+
'List entitlements and quota assignments of the global account (no scope), a directory or a subaccount. Subaccount scope returns the plans assigned to that subaccount; directory/global-account scope returns entitled plans with remaining amounts and, with service, every per-subaccount assignment of that service.',
|
|
441
|
+
z.object({ subaccount: guid.optional(), directory: guid.optional(), service: catalogName.optional(), top })
|
|
442
|
+
.refine(args => !(args.subaccount && args.directory), 'provide at most one of subaccount, directory'),
|
|
443
|
+
READ_ONLY_ANNOTATIONS,
|
|
444
|
+
async ({ subaccount, directory, service, top: limit }) => {
|
|
445
|
+
const result = await cli.json(['list', 'accounts/entitlement'], subaccount ? { subaccount } : directory ? { directory } : {});
|
|
446
|
+
const scope = subaccount
|
|
447
|
+
? { type: 'subaccount', id: subaccount }
|
|
448
|
+
: directory ? { type: 'directory', id: directory } : { type: 'global-account', id: null };
|
|
449
|
+
const services = list => rows(list).filter(entry => !service || entry.name === service);
|
|
450
|
+
const sorted = list => list.sort((left, right) => left.service.localeCompare(right.service) || String(left.plan).localeCompare(String(right.plan)));
|
|
451
|
+
const page = (key, list) => ({ [`${key}_total`]: list.length, [`${key}_returned`]: Math.min(list.length, limit), [key]: list.slice(0, limit) });
|
|
452
|
+
if (subaccount) {
|
|
453
|
+
const assigned = sorted(services(result?.assignedServices).flatMap(entry => rows(entry.servicePlans).flatMap(plan => rows(plan.assignmentInfo)
|
|
454
|
+
.filter(info => info.entityId === subaccount)
|
|
455
|
+
.map(info => ({
|
|
456
|
+
service: entry.name,
|
|
457
|
+
plan: plan.name,
|
|
458
|
+
category: plan.category,
|
|
459
|
+
amount: info.amount,
|
|
460
|
+
unlimited: info.unlimitedAmountAssigned === true,
|
|
461
|
+
entity_state: info.entityState,
|
|
462
|
+
state_message: info.stateMessage,
|
|
463
|
+
parent_remaining: info.parentRemainingAmount
|
|
464
|
+
})))));
|
|
465
|
+
return { scope, ...page('assigned', assigned) };
|
|
466
|
+
}
|
|
467
|
+
const entitled = sorted(services(result?.entitledServices).flatMap(entry => rows(entry.servicePlans).map(plan => ({
|
|
468
|
+
service: entry.name,
|
|
469
|
+
plan: plan.name,
|
|
470
|
+
category: plan.category,
|
|
471
|
+
amount: plan.amount,
|
|
472
|
+
remaining: plan.remainingAmount,
|
|
473
|
+
unlimited: plan.unlimited,
|
|
474
|
+
max_per_subaccount: plan.maxAllowedSubaccountQuota
|
|
475
|
+
}))));
|
|
476
|
+
if (!service) return { scope, ...page('entitled', entitled), assignments_omitted: 'pass service to list per-subaccount assignments' };
|
|
477
|
+
const assigned = sorted(services(result?.assignedServices).flatMap(entry => rows(entry.servicePlans).flatMap(plan => rows(plan.assignmentInfo)
|
|
478
|
+
.map(info => ({ service: entry.name, plan: plan.name, ...pick(info, ASSIGNMENT_FIELDS) })))));
|
|
479
|
+
return { scope, ...page('entitled', entitled), ...page('assigned', assigned) };
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
register('btp_check_quota', 'Check service quota',
|
|
483
|
+
'Check whether `amount` more instances of service plan <service>/<plan> can be created in a subaccount: catalog visibility, entitlement assignment state and, for numeric SERVICE quotas, existing instances of the plan versus the assigned amount.',
|
|
484
|
+
{ subaccount: guid, service: catalogName, plan: catalogName, amount: z.number().int().min(1).max(1000).default(1) },
|
|
485
|
+
READ_ONLY_ANNOTATIONS,
|
|
486
|
+
async args => checkQuota(args));
|
|
487
|
+
|
|
488
|
+
// ------------------------------------------------------------ instances and keys
|
|
489
|
+
|
|
490
|
+
register('btp_list_service_instances', 'List service instances',
|
|
491
|
+
'List Service Manager service instances of a subaccount (all environments) with offering and plan names, platform, CF org/space and readiness.',
|
|
492
|
+
{ subaccount: guid, offering: catalogName.optional(), plan: catalogName.optional(), name_contains: nameContains, top },
|
|
493
|
+
READ_ONLY_ANNOTATIONS,
|
|
494
|
+
async ({ subaccount, offering, plan, name_contains: needle, top: limit }) => {
|
|
495
|
+
const instances = await listInstances(subaccount);
|
|
496
|
+
const plans = await describePlans(subaccount, instances.map(instance => instance.service_plan_id));
|
|
497
|
+
const matching = instances
|
|
498
|
+
.map(instance => instanceRow(instance, plans.get(instance.service_plan_id)))
|
|
499
|
+
.filter(row => (!offering || row.offering === offering) && (!plan || row.plan === plan) && (!needle || contains(row.name, needle)))
|
|
500
|
+
.sort(byName);
|
|
501
|
+
return { total: matching.length, returned: Math.min(matching.length, limit), instances: matching.slice(0, limit) };
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
register('btp_get_service_instance', 'Get service instance',
|
|
505
|
+
'Read one service instance by id or name with its last operation and service keys (without credentials). include_parameters returns the redacted instance parameters when the offering allows retrieving them.',
|
|
506
|
+
z.object({ subaccount: guid, id: guid.optional(), name: lookupName.optional(), include_parameters: z.boolean().default(false) })
|
|
507
|
+
.refine(args => [args.id, args.name].filter(value => value !== undefined).length === 1, 'provide exactly one of id, name'),
|
|
508
|
+
READ_ONLY_ANNOTATIONS,
|
|
509
|
+
async ({ subaccount, id, name, include_parameters: withParameters }) => {
|
|
510
|
+
const raw = await getInstance(subaccount, { id, name });
|
|
511
|
+
const instance = await describeInstance(subaccount, raw);
|
|
512
|
+
const result = {
|
|
513
|
+
instance,
|
|
514
|
+
service_keys: (await bindingsOf(subaccount, instance.id)).map(binding => keyRow(binding, instance.name)).sort(byName)
|
|
515
|
+
};
|
|
516
|
+
if (withParameters) {
|
|
517
|
+
try {
|
|
518
|
+
const parameters = await cli.json(['get', 'services/instance'], { subaccount, id: instance.id, 'show-parameters': true });
|
|
519
|
+
result.parameters = redactCredentials(parameters?.data ?? parameters ?? {});
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error?.name !== 'IntegrationError') throw error;
|
|
522
|
+
result.parameters_error = error.message;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return result;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
register('btp_list_service_keys', 'List service keys',
|
|
529
|
+
'List service keys (Service Manager bindings) of a subaccount or of one instance. Credentials are never returned here; use btp_get_service_key for a redacted view.',
|
|
530
|
+
z.object({ subaccount: guid, instance_id: guid.optional(), instance_name: lookupName.optional(), top })
|
|
531
|
+
.refine(args => !(args.instance_id && args.instance_name), 'provide at most one of instance_id, instance_name'),
|
|
532
|
+
READ_ONLY_ANNOTATIONS,
|
|
533
|
+
async ({ subaccount, instance_id: instanceId, instance_name: instanceName, top: limit }) => {
|
|
534
|
+
let id = instanceId;
|
|
535
|
+
let names;
|
|
536
|
+
if (instanceName) {
|
|
537
|
+
const instance = await getInstance(subaccount, { name: instanceName });
|
|
538
|
+
id = instance?.id;
|
|
539
|
+
if (!id) throw new IntegrationError(`Service instance ${instanceName} was not found in subaccount ${subaccount}`);
|
|
540
|
+
names = new Map([[id, instance.name]]);
|
|
541
|
+
}
|
|
542
|
+
const bindings = id
|
|
543
|
+
? await bindingsOf(subaccount, id)
|
|
544
|
+
: rows(await cli.json(['list', 'services/binding'], { subaccount }));
|
|
545
|
+
names ??= new Map((await listInstances(subaccount)).map(instance => [instance.id, instance.name]));
|
|
546
|
+
const keys = bindings.map(binding => keyRow(binding, names.get(binding.service_instance_id))).sort(byName);
|
|
547
|
+
return { total: keys.length, returned: Math.min(keys.length, limit), service_keys: keys.slice(0, limit) };
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
register('btp_get_service_key', 'Get service key',
|
|
551
|
+
'Read one service key by id or name with its credentials redacted (URLs and client ids stay visible). The user fetches the full key in their own terminal.',
|
|
552
|
+
z.object({ subaccount: guid, id: guid.optional(), name: lookupName.optional() })
|
|
553
|
+
.refine(args => [args.id, args.name].filter(value => value !== undefined).length === 1, 'provide exactly one of id, name'),
|
|
554
|
+
READ_ONLY_ANNOTATIONS,
|
|
555
|
+
async ({ subaccount, id, name }) => keyResult(await getBinding(subaccount, { id, name }), subaccount));
|
|
556
|
+
|
|
557
|
+
// ------------------------------------------------------------ destinations and trust
|
|
558
|
+
|
|
559
|
+
register('btp_list_destinations', 'List destinations',
|
|
560
|
+
'List subaccount-level destinations, or instance-level destinations of one Destination service instance, without secret properties.',
|
|
561
|
+
{ subaccount: guid, service_instance: guid.optional(), name_contains: nameContains, top },
|
|
562
|
+
READ_ONLY_ANNOTATIONS,
|
|
563
|
+
async ({ subaccount, service_instance: serviceInstance, name_contains: needle, top: limit }) => {
|
|
564
|
+
const result = await cli.json(['list', 'connectivity/destination'], { subaccount, 'service-instance': serviceInstance });
|
|
565
|
+
const destinations = rows(result).map(row => {
|
|
566
|
+
const c = row?.destinationConfiguration ?? {};
|
|
567
|
+
return {
|
|
568
|
+
name: c.Name,
|
|
569
|
+
type: c.Type,
|
|
570
|
+
url: redactText(c.URL ?? ''),
|
|
571
|
+
authentication: c.Authentication,
|
|
572
|
+
proxy_type: c.ProxyType,
|
|
573
|
+
location_id: c.CloudConnectorLocationId ?? null,
|
|
574
|
+
description: c.Description ?? null,
|
|
575
|
+
modified: row?.systemMetadata?.modification_time
|
|
576
|
+
};
|
|
577
|
+
}).filter(row => !needle || contains(row.name, needle)).sort(byName);
|
|
578
|
+
return {
|
|
579
|
+
level: serviceInstance ? 'service-instance' : 'subaccount',
|
|
580
|
+
total: destinations.length,
|
|
581
|
+
returned: Math.min(destinations.length, limit),
|
|
582
|
+
destinations: destinations.slice(0, limit)
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
register('btp_get_destination', 'Get destination',
|
|
587
|
+
'Read one destination configuration with secret properties redacted.',
|
|
588
|
+
{ subaccount: guid, name: destinationName, service_instance: guid.optional() },
|
|
589
|
+
READ_ONLY_ANNOTATIONS,
|
|
590
|
+
async ({ subaccount, name, service_instance: serviceInstance }) => {
|
|
591
|
+
const { configuration, metadata } = await loadDestination(subaccount, name, serviceInstance);
|
|
592
|
+
return { destination: redactCredentials(configuration), system_metadata: pick(metadata, ['creation_time', 'modification_time']) };
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
register('btp_get_trust', 'Get trust configuration',
|
|
596
|
+
'Read the identity provider trust configurations of a subaccount and the active destination trust (SAML/OAuth signing certificate) with its expiry.',
|
|
597
|
+
{ subaccount: guid, include_certificate: z.boolean().default(false) },
|
|
598
|
+
READ_ONLY_ANNOTATIONS,
|
|
599
|
+
async ({ subaccount, include_certificate: withCertificate }) => {
|
|
600
|
+
const identityProviders = (await listTrust(subaccount)).map(row => pick(row, TRUST_FIELDS));
|
|
601
|
+
const { certificate_base64: certificate, ...trust } = await destinationTrust(subaccount);
|
|
602
|
+
return {
|
|
603
|
+
identity_providers: identityProviders,
|
|
604
|
+
destination_trust: withCertificate && certificate !== undefined ? { ...trust, certificate_base64: certificate } : trust
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
register('btp_list_cloud_connectors', 'List Cloud Connectors',
|
|
609
|
+
'List the Cloud Connectors connected to a subaccount with their exposed virtual systems, using the BTP-side Connectivity API. Needs a Connectivity service instance with plan api_access and a client-secret service key.',
|
|
610
|
+
{ subaccount: guid, instance_name: lookupName.optional() },
|
|
611
|
+
READ_ONLY_ANNOTATIONS,
|
|
612
|
+
async ({ subaccount, instance_name: instanceName }) => ({ subaccount, ...await connectivityApi(subaccount, instanceName) }));
|
|
613
|
+
|
|
614
|
+
register('btp_check_destination', 'Check destination',
|
|
615
|
+
'Verify an HTTP destination: URL, required authentication properties, principal-propagation prerequisites (proxy type, IdP trust), destination trust for SAML bearer, and for OnPremise destinations the Cloud Connector location and exposed virtual host. Items only visible in the Cloud Connector or backend are reported as unknown.',
|
|
616
|
+
{ subaccount: guid, name: destinationName, service_instance: guid.optional() },
|
|
617
|
+
READ_ONLY_ANNOTATIONS,
|
|
618
|
+
async ({ subaccount, name, service_instance: serviceInstance }) => {
|
|
619
|
+
const { configuration: c } = await loadDestination(subaccount, name, serviceInstance);
|
|
620
|
+
const checks = [];
|
|
621
|
+
const add = (id, status, detail) => checks.push({ id, status, detail });
|
|
622
|
+
let url = null;
|
|
623
|
+
if (c.Type !== 'HTTP') {
|
|
624
|
+
add('type', 'unknown', 'only HTTP destinations are verified');
|
|
625
|
+
} else {
|
|
626
|
+
try {
|
|
627
|
+
const parsed = new URL(c.URL);
|
|
628
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') url = parsed;
|
|
629
|
+
} catch {
|
|
630
|
+
// Reported below.
|
|
631
|
+
}
|
|
632
|
+
add('url', url ? 'pass' : 'fail', url ? `valid ${url.protocol.slice(0, -1)} URL` : 'URL is missing or not an http(s) URL');
|
|
633
|
+
|
|
634
|
+
const required = REQUIRED_AUTH_PROPERTIES[c.Authentication];
|
|
635
|
+
if (!required) {
|
|
636
|
+
add('authentication_properties', 'unknown', `authentication type ${c.Authentication ?? '(none)'} is not verified`);
|
|
637
|
+
} else {
|
|
638
|
+
const missing = required.filter(property => c[property] === undefined || c[property] === null || c[property] === '');
|
|
639
|
+
add('authentication_properties', missing.length ? 'fail' : 'pass', missing.length ? `missing properties: ${missing.join(', ')}` : `all properties for ${c.Authentication} are set`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (c.Authentication === 'PrincipalPropagation') {
|
|
643
|
+
add('proxy_type', c.ProxyType === 'OnPremise' ? 'pass' : 'fail', c.ProxyType === 'OnPremise' ? 'ProxyType is OnPremise' : 'PrincipalPropagation requires ProxyType OnPremise');
|
|
644
|
+
const active = (await listTrust(subaccount)).filter(trust => String(trust.status).toLowerCase() === 'active').map(trust => trust.originKey);
|
|
645
|
+
add('identity_provider_trust', active.length ? 'pass' : 'fail', active.length ? `active identity provider trust: ${active.join(', ')}` : 'no active identity provider trust in the subaccount');
|
|
646
|
+
add('cloud_connector_principal_propagation', 'unknown', PRINCIPAL_PROPAGATION_MANUAL);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (c.Authentication === 'OAuth2SAMLBearerAssertion') {
|
|
650
|
+
const trust = await destinationTrust(subaccount);
|
|
651
|
+
if (trust.error || trust.days_left === null || trust.days_left === undefined) add('destination_trust', 'fail', trust.error ? `destination trust could not be read: ${trust.error}` : 'no active destination trust certificate');
|
|
652
|
+
else if (trust.days_left < 0) add('destination_trust', 'fail', `destination trust certificate expired on ${trust.expires_at}`);
|
|
653
|
+
else if (trust.days_left < 30) add('destination_trust', 'warn', `destination trust certificate expires in ${trust.days_left} days (${trust.expires_at})`);
|
|
654
|
+
else add('destination_trust', 'pass', `destination trust certificate valid until ${trust.expires_at}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (c.ProxyType === 'OnPremise') {
|
|
658
|
+
let api;
|
|
659
|
+
try {
|
|
660
|
+
api = await connectivityApi(subaccount);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (error?.name !== 'IntegrationError') throw error;
|
|
663
|
+
add('cloud_connector', 'unknown', error.message);
|
|
664
|
+
}
|
|
665
|
+
if (api) {
|
|
666
|
+
const location = c.CloudConnectorLocationId ?? '';
|
|
667
|
+
const connectors = api.cloud_connectors;
|
|
668
|
+
let candidates = [];
|
|
669
|
+
if (!connectors.length) {
|
|
670
|
+
add('cloud_connector', 'fail', 'no Cloud Connector is connected to the subaccount');
|
|
671
|
+
} else if (location && !connectors.some(connector => connector.location_id)) {
|
|
672
|
+
add('cloud_connector', 'unknown', 'location ids not reported by the Connectivity API');
|
|
673
|
+
candidates = connectors;
|
|
674
|
+
} else {
|
|
675
|
+
candidates = connectors.filter(connector => (connector.location_id ?? '') === location);
|
|
676
|
+
add('cloud_connector', candidates.length ? 'pass' : 'fail', candidates.length
|
|
677
|
+
? `Cloud Connector connected for location ${location || '(default)'}`
|
|
678
|
+
: `no Cloud Connector is connected for location ${location || '(default)'}`);
|
|
679
|
+
}
|
|
680
|
+
if (candidates.length && url) {
|
|
681
|
+
const host = virtualHost(url);
|
|
682
|
+
const exposed = candidates.flatMap(connector => connector.exposed_systems).find(system => String(system.host ?? '').toLowerCase() === host);
|
|
683
|
+
add('exposed_system', exposed ? 'pass' : 'fail', exposed
|
|
684
|
+
? `virtual host ${host} is exposed (${exposed.protocol ?? 'unknown protocol'}, ${exposed.status ?? 'unknown status'})`
|
|
685
|
+
: `virtual host ${host} is not exposed by the Cloud Connector`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const summaryCounts = { pass: 0, warn: 0, fail: 0, unknown: 0 };
|
|
691
|
+
for (const check of checks) summaryCounts[check.status] += 1;
|
|
692
|
+
return {
|
|
693
|
+
destination: {
|
|
694
|
+
name: c.Name,
|
|
695
|
+
type: c.Type,
|
|
696
|
+
url: redactText(c.URL ?? ''),
|
|
697
|
+
authentication: c.Authentication,
|
|
698
|
+
proxy_type: c.ProxyType,
|
|
699
|
+
location_id: c.CloudConnectorLocationId ?? null
|
|
700
|
+
},
|
|
701
|
+
checks,
|
|
702
|
+
summary: summaryCounts
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ------------------------------------------------------------ confirmed writes
|
|
707
|
+
|
|
708
|
+
const timedOut = name => ({
|
|
709
|
+
created: 'unknown',
|
|
710
|
+
note: `The btp CLI did not answer within 120 s; check btp_list_service_instances / btp_list_service_keys for ${name} before retrying.`
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
register('btp_create_service_instance', 'Create service instance',
|
|
714
|
+
'Create a Service Manager service instance (Other environment, not a CF space instance) after a quota check. Parameters are passed through a private temporary file. Provisioning is asynchronous. Requires confirmation "CREATE BTP SERVICE INSTANCE <name>".',
|
|
715
|
+
{ subaccount: guid, offering: catalogName, plan: catalogName, name: newName, parameters: payload.optional(), confirmation },
|
|
716
|
+
CREATE_ANNOTATIONS,
|
|
717
|
+
async ({ subaccount, offering, plan, name, parameters, confirmation: given }) => {
|
|
718
|
+
requireConfirmation(given, `CREATE BTP SERVICE INSTANCE ${name}`);
|
|
719
|
+
const quota = await checkQuota({ subaccount, service: offering, plan, amount: 1 });
|
|
720
|
+
if (quota.verdict !== 'available') throw new IntegrationError(`No instance was created: ${quota.reasons.join('; ')}`);
|
|
721
|
+
let stdout;
|
|
722
|
+
try {
|
|
723
|
+
stdout = await withTempJson(parameters, file => cli.text(['create', 'services/instance'], {
|
|
724
|
+
subaccount, 'offering-name': offering, 'plan-name': plan, name, parameters: file
|
|
725
|
+
}));
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (error?.code === 'BTP_CLI_TIMEOUT') return timedOut(name);
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
const id = parseCliJson(stdout)?.id;
|
|
731
|
+
try {
|
|
732
|
+
const instance = await describeInstance(subaccount, await getInstance(subaccount, id ? { id } : { name }));
|
|
733
|
+
return { created: true, instance, note: 'Provisioning is asynchronous; poll btp_get_service_instance until last_operation.state is succeeded.' };
|
|
734
|
+
} catch (error) {
|
|
735
|
+
return { created: true, instance: null, read_back_error: error?.message ?? String(error) };
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
register('btp_create_service_key', 'Create service key',
|
|
740
|
+
'Create a service key (Service Manager binding) for an instance given by id or name. Parameters are passed through a private temporary file; returned credentials are redacted. Requires confirmation "CREATE BTP SERVICE KEY <name>".',
|
|
741
|
+
z.object({
|
|
742
|
+
subaccount: guid,
|
|
743
|
+
instance_id: guid.optional(),
|
|
744
|
+
instance_name: lookupName.optional(),
|
|
745
|
+
name: newName,
|
|
746
|
+
parameters: payload.optional(),
|
|
747
|
+
confirmation
|
|
748
|
+
}).refine(args => [args.instance_id, args.instance_name].filter(value => value !== undefined).length === 1, 'provide exactly one of instance_id, instance_name'),
|
|
749
|
+
CREATE_ANNOTATIONS,
|
|
750
|
+
async ({ subaccount, instance_id: instanceId, instance_name: instanceName, name, parameters, confirmation: given }) => {
|
|
751
|
+
requireConfirmation(given, `CREATE BTP SERVICE KEY ${name}`);
|
|
752
|
+
let stdout;
|
|
753
|
+
try {
|
|
754
|
+
stdout = await withTempJson(parameters, file => cli.text(['create', 'services/binding'], {
|
|
755
|
+
subaccount, binding: name, 'service-instance': instanceId, 'instance-name': instanceName, parameters: file
|
|
756
|
+
}));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
if (error?.code === 'BTP_CLI_TIMEOUT') return timedOut(name);
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
const id = parseCliJson(stdout)?.id;
|
|
762
|
+
let binding;
|
|
763
|
+
try {
|
|
764
|
+
binding = await getBinding(subaccount, id ? { id } : { name });
|
|
765
|
+
} catch (error) {
|
|
766
|
+
return { created: true, key: null, read_back_error: error?.message ?? String(error) };
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
created: true,
|
|
770
|
+
...keyResult(binding, subaccount),
|
|
771
|
+
...(binding?.ready === false ? { note: 'Binding is still being created; poll btp_get_service_key.' } : {})
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
return registeredNames;
|
|
776
|
+
}
|