Skip to content

Commit 7a8613b

Browse files
feat: Enable SSH tunnel in CF backend middleware for OnPremise destinations (#4520)
* feat: add ssh tunnel logic and adjust middleware * refactor: change destination types and returns * refactor: change method return type * refactor: move methods to adp-tooling * chore: add cset * Linting auto fix commit * refactor: improve code for ssh tunnel and env options * refactor: move btp http methods * fix: lint * Linting auto fix commit * fix: test * fix: test * feat: add changes and i18n routes injection * refactor: change texts --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 7ac1410 commit 7a8613b

25 files changed

Lines changed: 1707 additions & 162 deletions

File tree

.changeset/short-mangos-search.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sap-ux/backend-proxy-middleware-cf': patch
3+
'@sap-ux/adp-tooling': patch
4+
---
5+
6+
feat: Enable SSH tunnel in CF backend middleware for OnPremise destinations
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import axios from 'axios';
2+
3+
import type { ToolsLogger } from '@sap-ux/logger';
4+
5+
import { t } from '../i18n';
6+
import type { Uaa, BtpDestinationConfig } from '../types';
7+
8+
/**
9+
* Obtain an OAuth2 access token using the client credentials grant.
10+
*
11+
* @param uaa - UAA service credentials (clientid, clientsecret, url).
12+
* @param logger - Optional logger.
13+
* @returns OAuth2 access token.
14+
*/
15+
export async function getToken(uaa: Uaa, logger?: ToolsLogger): Promise<string> {
16+
const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`);
17+
const options = {
18+
headers: {
19+
'Content-Type': 'application/x-www-form-urlencoded',
20+
'Authorization': 'Basic ' + auth.toString('base64')
21+
}
22+
};
23+
const uri = `${uaa.url}/oauth/token`;
24+
logger?.debug(`Requesting OAuth token from ${uri}`);
25+
try {
26+
const response = await axios.post(uri, 'grant_type=client_credentials', options);
27+
logger?.debug('OAuth token obtained successfully');
28+
return response.data['access_token'];
29+
} catch (e) {
30+
logger?.error(`Failed to obtain OAuth token from ${uri}: ${e.message}`);
31+
throw new Error(t('error.failedToGetAuthKey', { error: e.message }));
32+
}
33+
}
34+
35+
/**
36+
* Get a single destination's configuration from the BTP Destination Configuration API.
37+
* Note: This calls the BTP Destination Configuration API, not the BAS listDestinations API.
38+
*
39+
* @param uri - Destination Configuration API base URI (e.g. https://destination-configuration.cfapps.us20.hana.ondemand.com).
40+
* @param token - OAuth2 bearer token obtained via {@link getToken}.
41+
* @param destinationName - Name of the destination to look up.
42+
* @param logger - Optional logger.
43+
* @returns The destinationConfiguration object (e.g. Name, ProxyType, URL, Authentication) or undefined on failure.
44+
*/
45+
export async function getBtpDestinationConfig(
46+
uri: string,
47+
token: string,
48+
destinationName: string,
49+
logger?: ToolsLogger
50+
): Promise<BtpDestinationConfig | undefined> {
51+
const url = `${uri}/destination-configuration/v1/destinations/${encodeURIComponent(destinationName)}`;
52+
logger?.debug(`Fetching BTP destination config for "${destinationName}" from ${url}`);
53+
54+
try {
55+
const response = await axios.get<{ destinationConfiguration?: BtpDestinationConfig }>(url, {
56+
headers: { 'Authorization': `Bearer ${token}` }
57+
});
58+
const config = response.data?.destinationConfiguration;
59+
logger?.debug(`Destination "${destinationName}" config: ProxyType=${config?.ProxyType}`);
60+
return config;
61+
} catch (e) {
62+
logger?.error(`Failed to fetch destination config for "${destinationName}": ${e.message}`);
63+
return undefined;
64+
}
65+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './api';

packages/adp-tooling/src/cf/app/html5-repo.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,12 @@ import type { ToolsLogger } from '@sap-ux/logger';
55
import type { Manifest } from '@sap-ux/project-access';
66

77
import { t } from '../../i18n';
8+
import type { HTML5Content, ServiceInfo, CfAppParams } from '../../types';
9+
import { getToken } from '../../btp/api';
810
import { getServiceNameByTags, getOrCreateServiceInstanceKeys, createServiceInstance } from '../services/api';
9-
import type { HTML5Content, ServiceInfo, Uaa, CfAppParams } from '../../types';
1011

1112
const HTML5_APPS_REPO_RUNTIME = 'html5-apps-repo-runtime';
1213

13-
/**
14-
* Get the OAuth token from HTML5 repository.
15-
*
16-
* @param {Uaa} uaa UAA credentials
17-
* @returns {Promise<string>} OAuth token
18-
*/
19-
export async function getToken(uaa: Uaa): Promise<string> {
20-
const auth = Buffer.from(`${uaa.clientid}:${uaa.clientsecret}`);
21-
const options = {
22-
headers: {
23-
'Content-Type': 'application/json',
24-
'Authorization': 'Basic ' + auth.toString('base64')
25-
}
26-
};
27-
const uri = `${uaa.url}/oauth/token?grant_type=client_credentials`;
28-
try {
29-
const response = await axios.get(uri, options);
30-
return response.data['access_token'];
31-
} catch (e) {
32-
throw new Error(t('error.failedToGetAuthKey', { error: e.message }));
33-
}
34-
}
35-
3614
/**
3715
* Download zip from HTML5 repository.
3816
*
@@ -109,7 +87,7 @@ export async function downloadAppContent(
10987
try {
11088
const { serviceKeys, serviceInstance } = await getHtml5RepoCredentials(spaceGuid, logger);
11189

112-
const token = await getToken(serviceKeys[0]?.credentials.uaa);
90+
const token = await getToken(serviceKeys[0]?.credentials.uaa, logger);
11391
const uri = `${serviceKeys[0]?.credentials.uri}/applications/content/${appNameVersion}?pathSuffixFilter=manifest.json,xs-app.json`;
11492
const zip = await downloadZip(token, appHostId, uri);
11593

packages/adp-tooling/src/cf/services/cli.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,52 @@ export async function requestCfApi<T = unknown>(url: string): Promise<T> {
151151
throw new Error(t('error.failedToRequestCFAPI', { error: e.message }));
152152
}
153153
}
154+
155+
/**
156+
* Check whether a CF app exists.
157+
*
158+
* @param appName - CF app name.
159+
* @returns True if the app exists.
160+
*/
161+
export async function checkAppExists(appName: string): Promise<boolean> {
162+
const result = await Cli.execute(['app', appName], ENV);
163+
return result.exitCode === 0;
164+
}
165+
166+
/**
167+
* Push a minimal no-route CF app from a given directory.
168+
*
169+
* @param appName - CF app name.
170+
* @param appPath - Local path to push.
171+
* @param args - Additional cf push arguments.
172+
*/
173+
export async function pushApp(appName: string, appPath: string, args: string[] = []): Promise<void> {
174+
const result = await Cli.execute(['push', appName, '-p', appPath, ...args], ENV);
175+
if (result.exitCode !== 0) {
176+
throw new Error(t('error.cfPushFailed', { appName, error: result.stderr }));
177+
}
178+
}
179+
180+
/**
181+
* Enable SSH access on a CF app.
182+
*
183+
* @param appName - CF app name.
184+
*/
185+
export async function enableSsh(appName: string): Promise<void> {
186+
const result = await Cli.execute(['enable-ssh', appName], ENV);
187+
if (result.exitCode !== 0) {
188+
throw new Error(t('error.cfEnableSshFailed', { appName, error: result.stderr }));
189+
}
190+
}
191+
192+
/**
193+
* Restart a CF app using rolling strategy.
194+
*
195+
* @param appName - CF app name.
196+
*/
197+
export async function restartApp(appName: string): Promise<void> {
198+
const result = await Cli.execute(['restart', appName, '--strategy', 'rolling', '--no-wait'], ENV);
199+
if (result.exitCode !== 0) {
200+
throw new Error(t('error.cfRestartFailed', { appName, error: result.stderr }));
201+
}
202+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './api';
2+
export * from './ssh';
23
export * from './cli';
34
export * from './manifest';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
import type { ToolsLogger } from '@sap-ux/logger';
6+
7+
import { checkAppExists, pushApp, enableSsh, restartApp } from './cli';
8+
9+
/**
10+
* Default CF app name used for SSH tunneling to the connectivity proxy.
11+
*/
12+
export const DEFAULT_TUNNEL_APP_NAME = 'adp-ssh-tunnel-app';
13+
14+
/**
15+
* Ensure a tunnel app exists in CF. If not found, deploy a minimal no-route app
16+
* using the binary_buildpack with minimum memory so it can serve as an SSH target.
17+
*
18+
* @param appName - CF app name.
19+
* @param logger - Logger instance.
20+
*/
21+
export async function ensureTunnelAppExists(appName: string, logger: ToolsLogger): Promise<void> {
22+
if (await checkAppExists(appName)) {
23+
logger.info(`Tunnel app "${appName}" already exists.`);
24+
return;
25+
}
26+
27+
logger.debug(`Tunnel app "${appName}" not found. Deploying minimal app...`);
28+
29+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-tunnel-'));
30+
fs.writeFileSync(path.join(tmpDir, '.keep'), '');
31+
32+
try {
33+
await pushApp(appName, tmpDir, [
34+
'--no-route',
35+
'-m',
36+
'64M',
37+
'-k',
38+
'256M',
39+
'-b',
40+
'binary_buildpack',
41+
'-c',
42+
'sleep infinity',
43+
'--health-check-type',
44+
'process'
45+
]);
46+
logger.info(`Tunnel app "${appName}" deployed successfully.`);
47+
} finally {
48+
fs.rmSync(tmpDir, { recursive: true, force: true });
49+
}
50+
}
51+
52+
/**
53+
* Enable SSH on a CF app and restart it.
54+
*
55+
* @param appName - CF app name.
56+
* @param logger - Logger instance.
57+
*/
58+
export async function enableSshAndRestart(appName: string, logger: ToolsLogger): Promise<void> {
59+
logger.info(`Enabling SSH on "${appName}"...`);
60+
await enableSsh(appName);
61+
62+
logger.info(`Restarting "${appName}"...`);
63+
await restartApp(appName);
64+
}

packages/adp-tooling/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './source';
66
export * from './ui5';
77
export * from './base/cf';
88
export * from './cf';
9+
export * from './btp';
910
export * from './base/helper';
1011
export * from './base/credentials';
1112
export * from './base/constants';

packages/adp-tooling/src/translations/adp-tooling.i18n.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@
114114
"noServiceInstanceNameFound": "No serviceInstanceName found in the app-variant-bundler-build configuration",
115115
"noServiceKeysFoundForInstance": "No service keys found for service instance: {{serviceInstanceName}}",
116116
"couldNotFetchServiceKeys": "Cannot fetch the service keys. Error: {{error}}",
117-
"metadataFetchingNotSupportedForCF": "Metadata fetching is not supported for Cloud Foundry projects."
117+
"metadataFetchingNotSupportedForCF": "Metadata fetching is not supported for Cloud Foundry projects.",
118+
"cfPushFailed": "cf push failed for the '{{appName}}' app: {{error}}",
119+
"cfEnableSshFailed": "cf enable-ssh failed for the '{{appName}}' app: {{error}}",
120+
"cfRestartFailed": "cf restart failed for the '{{appName}}' app: {{error}}"
118121
},
119122
"choices": {
120123
"true": "true",

packages/adp-tooling/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,23 @@ export interface ServiceKeyCredentialsWithTags {
943943
credentials: ServiceKeys['credentials'] | undefined;
944944
}
945945

946+
/**
947+
* Destination configuration returned by the BTP Destination Configuration API.
948+
* Contains the known properties; additional custom properties may also be present.
949+
*/
950+
export interface BtpDestinationConfig {
951+
Name: string;
952+
Type: string;
953+
URL: string;
954+
Authentication: string;
955+
ProxyType: string;
956+
Description?: string;
957+
User?: string;
958+
Password?: string;
959+
'sap-client'?: string;
960+
[key: string]: string | undefined;
961+
}
962+
946963
export interface AppRouterEnvOptions {
947964
'VCAP_SERVICES'?: Record<string, unknown>;
948965
destinations?: { name: string; url: string }[];

0 commit comments

Comments
 (0)