@saptools/cf-hana 0.4.1 → 0.5.1
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/CHANGELOG.md +64 -0
- package/README.md +91 -1
- package/dist/cli.js +1060 -142
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +35 -4
- package/dist/index.js +1024 -120
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
7
|
var CLI_NAME = "cf-hana";
|
|
8
|
-
var CLI_VERSION = "0.
|
|
8
|
+
var CLI_VERSION = "0.5.1";
|
|
9
9
|
var ENV_PREFIX = "CF_HANA";
|
|
10
10
|
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
11
11
|
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
@@ -18,6 +18,27 @@ var DEFAULT_RESULT_TTL_MINUTES = 10080;
|
|
|
18
18
|
var DEFAULT_RESULT_SEARCH_LIMIT = 20;
|
|
19
19
|
var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
|
|
20
20
|
var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
|
|
21
|
+
function resolvePositiveIntEnv(suffix, defaultValue) {
|
|
22
|
+
const raw = readEnv(envName(suffix));
|
|
23
|
+
if (raw === void 0) {
|
|
24
|
+
return defaultValue;
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number(raw);
|
|
27
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
28
|
+
}
|
|
29
|
+
var DEFAULT_TUNNEL_FALLBACK_BUDGET_MS = resolvePositiveIntEnv(
|
|
30
|
+
"TUNNEL_FALLBACK_BUDGET_MS",
|
|
31
|
+
25e3
|
|
32
|
+
);
|
|
33
|
+
var DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS = resolvePositiveIntEnv(
|
|
34
|
+
"TUNNEL_CANDIDATE_TIMEOUT_MS",
|
|
35
|
+
15e3
|
|
36
|
+
);
|
|
37
|
+
var DEFAULT_TUNNEL_MAX_CANDIDATES = resolvePositiveIntEnv("TUNNEL_MAX_CANDIDATES", 3);
|
|
38
|
+
var DEFAULT_TUNNEL_KEEPALIVE_SECONDS = resolvePositiveIntEnv(
|
|
39
|
+
"TUNNEL_KEEPALIVE_SECONDS",
|
|
40
|
+
1200
|
|
41
|
+
);
|
|
21
42
|
function resolveMaxResultStoreBytes() {
|
|
22
43
|
if (readEnv(envName("DRIVER")) !== "fake") {
|
|
23
44
|
return DEFAULT_MAX_RESULT_STORE_BYTES;
|
|
@@ -1052,7 +1073,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
|
1052
1073
|
}
|
|
1053
1074
|
|
|
1054
1075
|
// src/statements.ts
|
|
1055
|
-
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"
|
|
1076
|
+
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
|
|
1056
1077
|
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
1057
1078
|
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
1058
1079
|
function firstKeyword(sql) {
|
|
@@ -1092,8 +1113,165 @@ function firstKeyword(sql) {
|
|
|
1092
1113
|
}
|
|
1093
1114
|
return sql.slice(index, keywordEnd).toUpperCase();
|
|
1094
1115
|
}
|
|
1095
|
-
function
|
|
1096
|
-
const
|
|
1116
|
+
function skipQuotedText2(sql, start) {
|
|
1117
|
+
const quote = sql[start];
|
|
1118
|
+
let index = start + 1;
|
|
1119
|
+
while (index < sql.length) {
|
|
1120
|
+
if (sql[index] === quote) {
|
|
1121
|
+
if (sql[index + 1] === quote) {
|
|
1122
|
+
index += 2;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
index += 1;
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
index += 1;
|
|
1129
|
+
}
|
|
1130
|
+
return index;
|
|
1131
|
+
}
|
|
1132
|
+
function skipLineComment3(sql, start) {
|
|
1133
|
+
let index = start + 2;
|
|
1134
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
1135
|
+
index += 1;
|
|
1136
|
+
}
|
|
1137
|
+
return index;
|
|
1138
|
+
}
|
|
1139
|
+
function skipBlockComment3(sql, start) {
|
|
1140
|
+
let index = start + 2;
|
|
1141
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
1142
|
+
index += 1;
|
|
1143
|
+
}
|
|
1144
|
+
return Math.min(index + 2, sql.length);
|
|
1145
|
+
}
|
|
1146
|
+
function maskIgnoredSqlText(sql) {
|
|
1147
|
+
let masked = "";
|
|
1148
|
+
let index = 0;
|
|
1149
|
+
while (index < sql.length) {
|
|
1150
|
+
const char = sql[index];
|
|
1151
|
+
if (char === "'" || char === '"') {
|
|
1152
|
+
const end = skipQuotedText2(sql, index);
|
|
1153
|
+
masked += " ".repeat(end - index);
|
|
1154
|
+
index = end;
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
1158
|
+
const end = skipLineComment3(sql, index);
|
|
1159
|
+
masked += " ".repeat(end - index);
|
|
1160
|
+
index = end;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
1164
|
+
const end = skipBlockComment3(sql, index);
|
|
1165
|
+
masked += " ".repeat(end - index);
|
|
1166
|
+
index = end;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
masked += char ?? "";
|
|
1170
|
+
index += 1;
|
|
1171
|
+
}
|
|
1172
|
+
return masked;
|
|
1173
|
+
}
|
|
1174
|
+
function topLevelKeywordIndex(sql, keyword) {
|
|
1175
|
+
const masked = maskIgnoredSqlText(sql);
|
|
1176
|
+
let depth = 0;
|
|
1177
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
1178
|
+
const char = masked[index];
|
|
1179
|
+
if (char === "(") {
|
|
1180
|
+
depth += 1;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (char === ")") {
|
|
1184
|
+
depth = Math.max(0, depth - 1);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
1188
|
+
return index;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return void 0;
|
|
1192
|
+
}
|
|
1193
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
1194
|
+
return topLevelKeywordIndex(sql, keyword) !== void 0;
|
|
1195
|
+
}
|
|
1196
|
+
function isIdentifierChar2(char) {
|
|
1197
|
+
return /[A-Za-z0-9_$#]/.test(char);
|
|
1198
|
+
}
|
|
1199
|
+
function skipWhitespace(masked, start) {
|
|
1200
|
+
let index = start;
|
|
1201
|
+
while (index < masked.length && /\s/.test(masked.charAt(index))) {
|
|
1202
|
+
index += 1;
|
|
1203
|
+
}
|
|
1204
|
+
return index;
|
|
1205
|
+
}
|
|
1206
|
+
function matchingCloseParenIndex(masked, openIndex) {
|
|
1207
|
+
let depth = 0;
|
|
1208
|
+
for (let index = openIndex; index < masked.length; index += 1) {
|
|
1209
|
+
const char = masked.charAt(index);
|
|
1210
|
+
if (char === "(") {
|
|
1211
|
+
depth += 1;
|
|
1212
|
+
} else if (char === ")") {
|
|
1213
|
+
depth -= 1;
|
|
1214
|
+
if (depth === 0) {
|
|
1215
|
+
return index;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return void 0;
|
|
1220
|
+
}
|
|
1221
|
+
function isAsKeywordAt(masked, index) {
|
|
1222
|
+
return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
|
|
1223
|
+
}
|
|
1224
|
+
function cteListEndIndex(masked, afterWith) {
|
|
1225
|
+
let index = afterWith;
|
|
1226
|
+
for (; ; ) {
|
|
1227
|
+
index = skipWhitespace(masked, index);
|
|
1228
|
+
while (index < masked.length && isIdentifierChar2(masked.charAt(index))) {
|
|
1229
|
+
index += 1;
|
|
1230
|
+
}
|
|
1231
|
+
index = skipWhitespace(masked, index);
|
|
1232
|
+
if (masked.charAt(index) === "(") {
|
|
1233
|
+
const columnListEnd = matchingCloseParenIndex(masked, index);
|
|
1234
|
+
if (columnListEnd === void 0) {
|
|
1235
|
+
return void 0;
|
|
1236
|
+
}
|
|
1237
|
+
index = skipWhitespace(masked, columnListEnd + 1);
|
|
1238
|
+
}
|
|
1239
|
+
if (!isAsKeywordAt(masked, index)) {
|
|
1240
|
+
return void 0;
|
|
1241
|
+
}
|
|
1242
|
+
index = skipWhitespace(masked, index + 2);
|
|
1243
|
+
if (masked.charAt(index) !== "(") {
|
|
1244
|
+
return void 0;
|
|
1245
|
+
}
|
|
1246
|
+
const closeIndex = matchingCloseParenIndex(masked, index);
|
|
1247
|
+
if (closeIndex === void 0) {
|
|
1248
|
+
return void 0;
|
|
1249
|
+
}
|
|
1250
|
+
index = skipWhitespace(masked, closeIndex + 1);
|
|
1251
|
+
if (masked.charAt(index) === ",") {
|
|
1252
|
+
index += 1;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
return index;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
function resolveWithStatement(sql) {
|
|
1259
|
+
const withIndex = topLevelKeywordIndex(sql, "WITH");
|
|
1260
|
+
if (withIndex === void 0) {
|
|
1261
|
+
return void 0;
|
|
1262
|
+
}
|
|
1263
|
+
const masked = maskIgnoredSqlText(sql);
|
|
1264
|
+
const trailingIndex = cteListEndIndex(masked, withIndex + "WITH".length);
|
|
1265
|
+
if (trailingIndex === void 0) {
|
|
1266
|
+
return void 0;
|
|
1267
|
+
}
|
|
1268
|
+
let keywordEnd = trailingIndex;
|
|
1269
|
+
while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
|
|
1270
|
+
keywordEnd += 1;
|
|
1271
|
+
}
|
|
1272
|
+
return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
|
|
1273
|
+
}
|
|
1274
|
+
function classifyByKeyword(keyword) {
|
|
1097
1275
|
if (SELECT_KEYWORDS.has(keyword)) {
|
|
1098
1276
|
return "select";
|
|
1099
1277
|
}
|
|
@@ -1105,6 +1283,14 @@ function classifyStatement(sql) {
|
|
|
1105
1283
|
}
|
|
1106
1284
|
return "unknown";
|
|
1107
1285
|
}
|
|
1286
|
+
function classifyStatement(sql) {
|
|
1287
|
+
const keyword = firstKeyword(sql);
|
|
1288
|
+
if (keyword === "WITH") {
|
|
1289
|
+
const resolved = resolveWithStatement(sql);
|
|
1290
|
+
return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
|
|
1291
|
+
}
|
|
1292
|
+
return classifyByKeyword(keyword);
|
|
1293
|
+
}
|
|
1108
1294
|
function quoteIdentifier(identifier) {
|
|
1109
1295
|
if (identifier.length === 0) {
|
|
1110
1296
|
throw new QueryError("A SQL identifier must not be empty");
|
|
@@ -1268,11 +1454,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
|
|
|
1268
1454
|
return target.reference;
|
|
1269
1455
|
}
|
|
1270
1456
|
if (/^PARTITION\b/i.test(suffix)) {
|
|
1271
|
-
const
|
|
1272
|
-
if (
|
|
1457
|
+
const open2 = suffix.indexOf("(");
|
|
1458
|
+
if (open2 === -1) {
|
|
1273
1459
|
return void 0;
|
|
1274
1460
|
}
|
|
1275
|
-
const close = findTopLevelChar(suffix, ")",
|
|
1461
|
+
const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
|
|
1276
1462
|
if (close === void 0) {
|
|
1277
1463
|
return void 0;
|
|
1278
1464
|
}
|
|
@@ -1455,13 +1641,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
|
1455
1641
|
}
|
|
1456
1642
|
}
|
|
1457
1643
|
function buildWriteBackupPlan(sql, params = []) {
|
|
1458
|
-
const
|
|
1459
|
-
const
|
|
1644
|
+
const trimmed = trimStatementSql(sql);
|
|
1645
|
+
const leading = firstKeyword(trimmed);
|
|
1646
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
|
|
1647
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
1460
1648
|
if (!isWriteKeyword(keyword)) {
|
|
1461
1649
|
return void 0;
|
|
1462
1650
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1651
|
+
const startIndex = withResolved?.index ?? 0;
|
|
1652
|
+
const statementSql = trimmed.slice(startIndex);
|
|
1653
|
+
const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
|
|
1654
|
+
assertParamArity(statementSql, slicedParams);
|
|
1655
|
+
const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
|
|
1656
|
+
return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
|
|
1465
1657
|
}
|
|
1466
1658
|
|
|
1467
1659
|
// src/backup.ts
|
|
@@ -1790,9 +1982,9 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
1790
1982
|
env["CF_HOME"] = ctx.cfHome;
|
|
1791
1983
|
return env;
|
|
1792
1984
|
}
|
|
1793
|
-
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1985
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
|
|
1794
1986
|
let lastErr;
|
|
1795
|
-
for (let attempt = 0; attempt <
|
|
1987
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1796
1988
|
try {
|
|
1797
1989
|
const { stdout } = await execFileAsync(bin, args, {
|
|
1798
1990
|
env,
|
|
@@ -1811,18 +2003,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
1811
2003
|
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1812
2004
|
throw err;
|
|
1813
2005
|
}
|
|
1814
|
-
if (attempt <
|
|
2006
|
+
if (attempt < maxAttempts - 1) {
|
|
1815
2007
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
1816
2008
|
}
|
|
1817
2009
|
}
|
|
1818
2010
|
}
|
|
1819
2011
|
throw lastErr;
|
|
1820
2012
|
}
|
|
1821
|
-
async function runCf(args, ctx, overrides = {}) {
|
|
2013
|
+
async function runCf(args, ctx, overrides = {}, execOptions = {}) {
|
|
1822
2014
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1823
2015
|
const env = buildEnv(ctx, overrides);
|
|
1824
2016
|
try {
|
|
1825
|
-
return await execWithRetries(
|
|
2017
|
+
return await execWithRetries(
|
|
2018
|
+
bin,
|
|
2019
|
+
[...argsPrefix, ...args],
|
|
2020
|
+
env,
|
|
2021
|
+
execOptions.timeoutMs,
|
|
2022
|
+
execOptions.maxAttempts
|
|
2023
|
+
);
|
|
1826
2024
|
} catch (lastErr) {
|
|
1827
2025
|
const e = lastErr;
|
|
1828
2026
|
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
@@ -1852,6 +2050,27 @@ async function cfEnvDirect(appName) {
|
|
|
1852
2050
|
delete env["SAP_PASSWORD"];
|
|
1853
2051
|
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
1854
2052
|
}
|
|
2053
|
+
async function cfApps(ctx, timeoutMs) {
|
|
2054
|
+
return await runCf(
|
|
2055
|
+
["apps"],
|
|
2056
|
+
ctx,
|
|
2057
|
+
{},
|
|
2058
|
+
timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
async function cfAppsDirect(timeoutMs) {
|
|
2062
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
2063
|
+
const env = { ...process.env };
|
|
2064
|
+
delete env["SAP_EMAIL"];
|
|
2065
|
+
delete env["SAP_PASSWORD"];
|
|
2066
|
+
return await execWithRetries(
|
|
2067
|
+
bin,
|
|
2068
|
+
[...argsPrefix, "apps"],
|
|
2069
|
+
env,
|
|
2070
|
+
timeoutMs,
|
|
2071
|
+
timeoutMs === void 0 ? void 0 : 1
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
1855
2074
|
async function readCurrentCfTarget() {
|
|
1856
2075
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1857
2076
|
const env = { ...process.env };
|
|
@@ -1901,6 +2120,26 @@ function parseTargetFields(stdout) {
|
|
|
1901
2120
|
}
|
|
1902
2121
|
return map;
|
|
1903
2122
|
}
|
|
2123
|
+
function parseCfAppsOutput(stdout) {
|
|
2124
|
+
const lines = stdout.split(/\r?\n/);
|
|
2125
|
+
const headerIndex = lines.findIndex((line) => /^\s*name\s+\S/i.test(line));
|
|
2126
|
+
if (headerIndex === -1) {
|
|
2127
|
+
return [];
|
|
2128
|
+
}
|
|
2129
|
+
const rows = [];
|
|
2130
|
+
for (const line of lines.slice(headerIndex + 1)) {
|
|
2131
|
+
const trimmed = line.trim();
|
|
2132
|
+
if (trimmed.length === 0) {
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
const [name, state] = trimmed.split(/\s+/);
|
|
2136
|
+
if (name === void 0 || state === void 0) {
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
2139
|
+
rows.push({ name, state });
|
|
2140
|
+
}
|
|
2141
|
+
return rows;
|
|
2142
|
+
}
|
|
1904
2143
|
function formatCurrentCfAppSelector(target, appName) {
|
|
1905
2144
|
const name = appName.trim();
|
|
1906
2145
|
if (!name) {
|
|
@@ -2212,7 +2451,10 @@ async function resolveAppBindings(rawSelector, options) {
|
|
|
2212
2451
|
source: "live",
|
|
2213
2452
|
selectorSource: target.selectorSource,
|
|
2214
2453
|
regionConfirmed: target.regionConfirmed,
|
|
2215
|
-
selectorCanBePinned: target.selectorCanBePinned
|
|
2454
|
+
selectorCanBePinned: target.selectorCanBePinned,
|
|
2455
|
+
apiEndpoint: target.apiEndpoint,
|
|
2456
|
+
orgName: target.orgName,
|
|
2457
|
+
spaceName: target.spaceName
|
|
2216
2458
|
};
|
|
2217
2459
|
}
|
|
2218
2460
|
async function readExplicitBindings(target, options) {
|
|
@@ -2278,6 +2520,7 @@ function toConnectionTarget(binding, role) {
|
|
|
2278
2520
|
// src/driver/fake.ts
|
|
2279
2521
|
import { appendFile } from "fs/promises";
|
|
2280
2522
|
var catalogFailureInjected = false;
|
|
2523
|
+
var connectFailureInjectedOnce = false;
|
|
2281
2524
|
function catalogObjects() {
|
|
2282
2525
|
return [
|
|
2283
2526
|
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
|
|
@@ -2486,10 +2729,28 @@ var FakeConnection = class {
|
|
|
2486
2729
|
return this.closed;
|
|
2487
2730
|
}
|
|
2488
2731
|
};
|
|
2732
|
+
function maybeFailConnect() {
|
|
2733
|
+
const mode = readEnv(envName("FAKE_FAIL_CONNECT"));
|
|
2734
|
+
if (mode !== "once" && mode !== "always") {
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (mode === "once" && connectFailureInjectedOnce) {
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
connectFailureInjectedOnce = true;
|
|
2741
|
+
const cause = Object.assign(
|
|
2742
|
+
new Error("Could not connect to any host: [ fake-host:443 - forced fixture failure ]"),
|
|
2743
|
+
{ code: "EHDBOPENCONN" }
|
|
2744
|
+
);
|
|
2745
|
+
throw new CfHanaError("CONNECTION", `Failed to connect to HANA: ${cause.message}`, { cause });
|
|
2746
|
+
}
|
|
2489
2747
|
function createFakeDriver() {
|
|
2490
2748
|
return {
|
|
2491
2749
|
name: "fake",
|
|
2492
|
-
connect: (_params) =>
|
|
2750
|
+
connect: (_params) => {
|
|
2751
|
+
maybeFailConnect();
|
|
2752
|
+
return Promise.resolve(new FakeConnection());
|
|
2753
|
+
}
|
|
2493
2754
|
};
|
|
2494
2755
|
}
|
|
2495
2756
|
|
|
@@ -2714,7 +2975,15 @@ async function connectHdb(params) {
|
|
|
2714
2975
|
user: params.user,
|
|
2715
2976
|
password: params.password,
|
|
2716
2977
|
ca: params.certificate,
|
|
2717
|
-
useTLS: true
|
|
2978
|
+
useTLS: true,
|
|
2979
|
+
// HANA Cloud can reactively redirect a connection mid-auth to an
|
|
2980
|
+
// internal per-node hostname (pod-locality routing). That target is
|
|
2981
|
+
// often unreachable outside SAP's own network, and for a tunneled
|
|
2982
|
+
// connection it would silently abandon the SSH forward for a fresh,
|
|
2983
|
+
// untunneled socket. The original bound host is always a real, working
|
|
2984
|
+
// endpoint, so disabling the redirect is safe on the direct path too.
|
|
2985
|
+
disableCloudRedirect: true,
|
|
2986
|
+
...params.servername === void 0 ? {} : { servername: params.servername }
|
|
2718
2987
|
});
|
|
2719
2988
|
await openClient(client, params.connectTimeoutMs);
|
|
2720
2989
|
try {
|
|
@@ -2823,83 +3092,6 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2823
3092
|
// src/safety.ts
|
|
2824
3093
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
2825
3094
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
2826
|
-
function skipQuotedText2(sql, start) {
|
|
2827
|
-
const quote = sql[start];
|
|
2828
|
-
let index = start + 1;
|
|
2829
|
-
while (index < sql.length) {
|
|
2830
|
-
if (sql[index] === quote) {
|
|
2831
|
-
if (sql[index + 1] === quote) {
|
|
2832
|
-
index += 2;
|
|
2833
|
-
continue;
|
|
2834
|
-
}
|
|
2835
|
-
index += 1;
|
|
2836
|
-
break;
|
|
2837
|
-
}
|
|
2838
|
-
index += 1;
|
|
2839
|
-
}
|
|
2840
|
-
return index;
|
|
2841
|
-
}
|
|
2842
|
-
function skipLineComment3(sql, start) {
|
|
2843
|
-
let index = start + 2;
|
|
2844
|
-
while (index < sql.length && sql[index] !== "\n") {
|
|
2845
|
-
index += 1;
|
|
2846
|
-
}
|
|
2847
|
-
return index;
|
|
2848
|
-
}
|
|
2849
|
-
function skipBlockComment3(sql, start) {
|
|
2850
|
-
let index = start + 2;
|
|
2851
|
-
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
2852
|
-
index += 1;
|
|
2853
|
-
}
|
|
2854
|
-
return Math.min(index + 2, sql.length);
|
|
2855
|
-
}
|
|
2856
|
-
function maskIgnoredSqlText(sql) {
|
|
2857
|
-
let masked = "";
|
|
2858
|
-
let index = 0;
|
|
2859
|
-
while (index < sql.length) {
|
|
2860
|
-
const char = sql[index];
|
|
2861
|
-
if (char === "'" || char === '"') {
|
|
2862
|
-
const end = skipQuotedText2(sql, index);
|
|
2863
|
-
masked += " ".repeat(end - index);
|
|
2864
|
-
index = end;
|
|
2865
|
-
continue;
|
|
2866
|
-
}
|
|
2867
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
2868
|
-
const end = skipLineComment3(sql, index);
|
|
2869
|
-
masked += " ".repeat(end - index);
|
|
2870
|
-
index = end;
|
|
2871
|
-
continue;
|
|
2872
|
-
}
|
|
2873
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
2874
|
-
const end = skipBlockComment3(sql, index);
|
|
2875
|
-
masked += " ".repeat(end - index);
|
|
2876
|
-
index = end;
|
|
2877
|
-
continue;
|
|
2878
|
-
}
|
|
2879
|
-
masked += char ?? "";
|
|
2880
|
-
index += 1;
|
|
2881
|
-
}
|
|
2882
|
-
return masked;
|
|
2883
|
-
}
|
|
2884
|
-
function hasTopLevelKeyword(sql, keyword) {
|
|
2885
|
-
const masked = maskIgnoredSqlText(sql);
|
|
2886
|
-
let depth = 0;
|
|
2887
|
-
for (let index = 0; index < masked.length; index += 1) {
|
|
2888
|
-
const char = masked[index];
|
|
2889
|
-
if (char === "(") {
|
|
2890
|
-
depth += 1;
|
|
2891
|
-
continue;
|
|
2892
|
-
}
|
|
2893
|
-
if (char === ")") {
|
|
2894
|
-
depth = Math.max(0, depth - 1);
|
|
2895
|
-
continue;
|
|
2896
|
-
}
|
|
2897
|
-
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
2898
|
-
return true;
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
return false;
|
|
2902
|
-
}
|
|
2903
3095
|
function isUnconditionalMergeDelete(sql) {
|
|
2904
3096
|
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2905
3097
|
}
|
|
@@ -2942,17 +3134,23 @@ function appendLimit(sql, limit) {
|
|
|
2942
3134
|
}
|
|
2943
3135
|
function inspectStatement(sql) {
|
|
2944
3136
|
const kind = classifyStatement(sql);
|
|
2945
|
-
const
|
|
3137
|
+
const leading = firstKeyword(sql);
|
|
3138
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
|
|
3139
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
3140
|
+
const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
|
|
2946
3141
|
if (kind === "ddl") {
|
|
2947
3142
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
2948
3143
|
}
|
|
2949
3144
|
if (kind === "dml") {
|
|
2950
|
-
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(
|
|
3145
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
|
|
2951
3146
|
return {
|
|
2952
3147
|
kind,
|
|
2953
3148
|
destructive
|
|
2954
3149
|
};
|
|
2955
3150
|
}
|
|
3151
|
+
if (kind === "unknown") {
|
|
3152
|
+
return { kind, destructive: keyword === "CALL" };
|
|
3153
|
+
}
|
|
2956
3154
|
return { kind, destructive: false };
|
|
2957
3155
|
}
|
|
2958
3156
|
function evaluateGuard(sql, config) {
|
|
@@ -2995,6 +3193,707 @@ function applyAutoLimit(sql, limit) {
|
|
|
2995
3193
|
};
|
|
2996
3194
|
}
|
|
2997
3195
|
|
|
3196
|
+
// src/tunnel/cache.ts
|
|
3197
|
+
import { createHash as createHash2 } from "crypto";
|
|
3198
|
+
import { mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
3199
|
+
import { homedir as homedir4 } from "os";
|
|
3200
|
+
import { join as join6 } from "path";
|
|
3201
|
+
|
|
3202
|
+
// src/tunnel/process.ts
|
|
3203
|
+
import { spawn } from "child_process";
|
|
3204
|
+
import { mkdtemp as mkdtemp2, open, readFile as readFile2, rm as rm4 } from "fs/promises";
|
|
3205
|
+
import { connect as netConnect, createServer } from "net";
|
|
3206
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3207
|
+
import { join as join5 } from "path";
|
|
3208
|
+
var PORT_POLL_INTERVAL_MS = 100;
|
|
3209
|
+
var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
|
|
3210
|
+
function assertPositiveSafeInteger(label, value) {
|
|
3211
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
3212
|
+
throw new CfHanaError("CONFIG", `${label} must be a positive integer`);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
async function allocateLocalPort() {
|
|
3216
|
+
return await new Promise((resolve, reject) => {
|
|
3217
|
+
const server = createServer();
|
|
3218
|
+
server.once("error", reject);
|
|
3219
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3220
|
+
const address = server.address();
|
|
3221
|
+
server.close(() => {
|
|
3222
|
+
if (address === null || typeof address === "string") {
|
|
3223
|
+
reject(new CfHanaError("CONNECTION", "Could not allocate a local port for a tunnel"));
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
3226
|
+
resolve(address.port);
|
|
3227
|
+
});
|
|
3228
|
+
});
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
function probeLocalPort(port, timeoutMs = DEFAULT_PORT_PROBE_TIMEOUT_MS) {
|
|
3232
|
+
return new Promise((resolve) => {
|
|
3233
|
+
let settled = false;
|
|
3234
|
+
const socket = netConnect({ host: "127.0.0.1", port });
|
|
3235
|
+
const finish = (result) => {
|
|
3236
|
+
if (settled) {
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
settled = true;
|
|
3240
|
+
clearTimeout(timer);
|
|
3241
|
+
socket.destroy();
|
|
3242
|
+
resolve(result);
|
|
3243
|
+
};
|
|
3244
|
+
const timer = setTimeout(() => {
|
|
3245
|
+
finish(false);
|
|
3246
|
+
}, timeoutMs);
|
|
3247
|
+
timer.unref();
|
|
3248
|
+
socket.once("connect", () => {
|
|
3249
|
+
finish(true);
|
|
3250
|
+
});
|
|
3251
|
+
socket.once("error", () => {
|
|
3252
|
+
finish(false);
|
|
3253
|
+
});
|
|
3254
|
+
});
|
|
3255
|
+
}
|
|
3256
|
+
function killTunnelProcess(pid) {
|
|
3257
|
+
if (pid === void 0) {
|
|
3258
|
+
return;
|
|
3259
|
+
}
|
|
3260
|
+
try {
|
|
3261
|
+
process.kill(pid, "SIGTERM");
|
|
3262
|
+
} catch {
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
async function withScopedCfSession(selectorSource, target, sapCredentials, work) {
|
|
3266
|
+
if (selectorSource === "ambient") {
|
|
3267
|
+
return await work();
|
|
3268
|
+
}
|
|
3269
|
+
if (sapCredentials === void 0) {
|
|
3270
|
+
throw new CredentialsNotFoundError(
|
|
3271
|
+
"SAP credentials are required to open a tunnel session for an explicit selector"
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
const cfHome = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-"));
|
|
3275
|
+
try {
|
|
3276
|
+
const ctx = { cfHome };
|
|
3277
|
+
await cfApi(target.apiEndpoint, ctx);
|
|
3278
|
+
await cfAuth(sapCredentials.email, sapCredentials.password, ctx);
|
|
3279
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
3280
|
+
return await work(ctx);
|
|
3281
|
+
} finally {
|
|
3282
|
+
await rm4(cfHome, { recursive: true, force: true });
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
|
|
3286
|
+
return new Promise((resolve) => {
|
|
3287
|
+
let settled = false;
|
|
3288
|
+
const timers = {};
|
|
3289
|
+
const finish = (result) => {
|
|
3290
|
+
if (settled) {
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
settled = true;
|
|
3294
|
+
clearInterval(timers.poll);
|
|
3295
|
+
clearTimeout(timers.deadline);
|
|
3296
|
+
child.removeListener("exit", onAbort);
|
|
3297
|
+
child.removeListener("error", onAbort);
|
|
3298
|
+
if (result === void 0) {
|
|
3299
|
+
killProcess(child.pid);
|
|
3300
|
+
} else {
|
|
3301
|
+
child.unref();
|
|
3302
|
+
}
|
|
3303
|
+
resolve(result);
|
|
3304
|
+
};
|
|
3305
|
+
function onAbort() {
|
|
3306
|
+
finish();
|
|
3307
|
+
}
|
|
3308
|
+
child.on("exit", onAbort);
|
|
3309
|
+
child.on("error", onAbort);
|
|
3310
|
+
timers.poll = setInterval(() => {
|
|
3311
|
+
void probePort(localPort).then((open2) => {
|
|
3312
|
+
if (open2 && child.pid !== void 0) {
|
|
3313
|
+
finish({ localPort, pid: child.pid });
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
}, PORT_POLL_INTERVAL_MS);
|
|
3317
|
+
timers.deadline = setTimeout(finish, boundMs);
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
var STDERR_TAIL_MAX_CHARS = 4096;
|
|
3321
|
+
async function openStderrCapture() {
|
|
3322
|
+
try {
|
|
3323
|
+
const dir = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
|
|
3324
|
+
const path = join5(dir, "stderr.log");
|
|
3325
|
+
const handle = await open(path, "w");
|
|
3326
|
+
return {
|
|
3327
|
+
path,
|
|
3328
|
+
fd: handle.fd,
|
|
3329
|
+
close: async () => {
|
|
3330
|
+
await handle.close().catch(() => {
|
|
3331
|
+
});
|
|
3332
|
+
await rm4(dir, { recursive: true, force: true });
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
} catch {
|
|
3336
|
+
return void 0;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
async function readStderrTail(path) {
|
|
3340
|
+
try {
|
|
3341
|
+
const content = (await readFile2(path, "utf8")).trim().replace(/\s+/g, " ");
|
|
3342
|
+
if (content.length === 0) {
|
|
3343
|
+
return void 0;
|
|
3344
|
+
}
|
|
3345
|
+
return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
|
|
3346
|
+
} catch {
|
|
3347
|
+
return void 0;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
async function spawnTunnel(params, deps = {}) {
|
|
3351
|
+
assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
|
|
3352
|
+
const remainingMs = params.deadline - Date.now();
|
|
3353
|
+
if (remainingMs <= 0) {
|
|
3354
|
+
return void 0;
|
|
3355
|
+
}
|
|
3356
|
+
const boundMs = Math.min(remainingMs, params.candidateTimeoutMs);
|
|
3357
|
+
const spawnProcess = deps.spawnProcess ?? spawn;
|
|
3358
|
+
const probePort = deps.probePort ?? probeLocalPort;
|
|
3359
|
+
const allocatePort = deps.allocatePort ?? allocateLocalPort;
|
|
3360
|
+
const killProcess = deps.killProcess ?? killTunnelProcess;
|
|
3361
|
+
const localPort = await allocatePort();
|
|
3362
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
3363
|
+
const forward = `${String(localPort)}:${params.hanaHost}:${String(params.hanaPort)}`;
|
|
3364
|
+
const args = [
|
|
3365
|
+
...argsPrefix,
|
|
3366
|
+
"ssh",
|
|
3367
|
+
params.app,
|
|
3368
|
+
"-L",
|
|
3369
|
+
forward,
|
|
3370
|
+
"-c",
|
|
3371
|
+
`sleep ${String(params.keepaliveSeconds)}`
|
|
3372
|
+
];
|
|
3373
|
+
const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
|
|
3374
|
+
const openCapture = deps.openStderrCapture ?? openStderrCapture;
|
|
3375
|
+
const capture = await openCapture();
|
|
3376
|
+
const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
|
|
3377
|
+
const child = spawnProcess(bin, args, { detached: true, stdio, env });
|
|
3378
|
+
const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
|
|
3379
|
+
if (capture !== void 0) {
|
|
3380
|
+
if (result === void 0) {
|
|
3381
|
+
try {
|
|
3382
|
+
const tail = await readStderrTail(capture.path);
|
|
3383
|
+
if (tail !== void 0) {
|
|
3384
|
+
deps.onFailureDiagnostic?.(tail);
|
|
3385
|
+
}
|
|
3386
|
+
} catch {
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
await capture.close();
|
|
3390
|
+
}
|
|
3391
|
+
return result;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// src/tunnel/cache.ts
|
|
3395
|
+
var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
|
|
3396
|
+
var DEFAULT_POLL_INTERVAL_MS = 300;
|
|
3397
|
+
function isRecord3(value) {
|
|
3398
|
+
return typeof value === "object" && value !== null;
|
|
3399
|
+
}
|
|
3400
|
+
function defaultIsProcessAlive(pid) {
|
|
3401
|
+
try {
|
|
3402
|
+
process.kill(pid, 0);
|
|
3403
|
+
return true;
|
|
3404
|
+
} catch (error) {
|
|
3405
|
+
return isRecord3(error) && error["code"] !== "ESRCH";
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
function sleep(ms) {
|
|
3409
|
+
return new Promise((resolve) => {
|
|
3410
|
+
setTimeout(resolve, ms);
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
function tunnelCacheRoot(saptoolsRoot) {
|
|
3414
|
+
return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "tunnel");
|
|
3415
|
+
}
|
|
3416
|
+
function tunnelCacheKey(host) {
|
|
3417
|
+
return createHash2("sha256").update(host).digest("hex");
|
|
3418
|
+
}
|
|
3419
|
+
function tunnelCachePath(host, saptoolsRoot) {
|
|
3420
|
+
return join6(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
|
|
3421
|
+
}
|
|
3422
|
+
function isOrgKey(value) {
|
|
3423
|
+
return isRecord3(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
|
|
3424
|
+
}
|
|
3425
|
+
function hasEstablishingFields(value) {
|
|
3426
|
+
return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
|
|
3427
|
+
}
|
|
3428
|
+
function hasReadyFields(value) {
|
|
3429
|
+
return value["status"] === "ready" && typeof value["host"] === "string" && typeof value["localPort"] === "number" && typeof value["pid"] === "number" && typeof value["app"] === "string" && typeof value["expiresAt"] === "string" && isOrgKey(value["orgKey"]);
|
|
3430
|
+
}
|
|
3431
|
+
function isTunnelCacheRecord(value) {
|
|
3432
|
+
return isRecord3(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
|
|
3433
|
+
}
|
|
3434
|
+
async function parseTunnelCacheFile(path) {
|
|
3435
|
+
try {
|
|
3436
|
+
const raw = JSON.parse(await readFile3(path, "utf8"));
|
|
3437
|
+
return isTunnelCacheRecord(raw) ? raw : void 0;
|
|
3438
|
+
} catch {
|
|
3439
|
+
return void 0;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
async function readTunnelCacheEntry(host, options = {}) {
|
|
3443
|
+
const entry = await parseTunnelCacheFile(tunnelCachePath(host, options.saptoolsRoot));
|
|
3444
|
+
return entry?.host === host ? entry : void 0;
|
|
3445
|
+
}
|
|
3446
|
+
function isExpired(entry, now) {
|
|
3447
|
+
const expiresAt = Date.parse(entry.expiresAt);
|
|
3448
|
+
return !Number.isFinite(expiresAt) || now.getTime() >= expiresAt;
|
|
3449
|
+
}
|
|
3450
|
+
async function isTunnelUsable(entry, options = {}) {
|
|
3451
|
+
const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
3452
|
+
if (!isAlive(entry.pid)) {
|
|
3453
|
+
return false;
|
|
3454
|
+
}
|
|
3455
|
+
if (isExpired(entry, options.now?.() ?? /* @__PURE__ */ new Date())) {
|
|
3456
|
+
return false;
|
|
3457
|
+
}
|
|
3458
|
+
const probePort = options.probePort ?? probeLocalPort;
|
|
3459
|
+
return await probePort(entry.localPort);
|
|
3460
|
+
}
|
|
3461
|
+
function isEstablishingStale(entry, options) {
|
|
3462
|
+
const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
3463
|
+
if (!isAlive(entry.ownerPid)) {
|
|
3464
|
+
return true;
|
|
3465
|
+
}
|
|
3466
|
+
const startedAt = Date.parse(entry.startedAt);
|
|
3467
|
+
if (!Number.isFinite(startedAt)) {
|
|
3468
|
+
return true;
|
|
3469
|
+
}
|
|
3470
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
3471
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_ESTABLISHING_STALE_MS;
|
|
3472
|
+
return now.getTime() - startedAt >= staleAfterMs;
|
|
3473
|
+
}
|
|
3474
|
+
async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
|
|
3475
|
+
const root = tunnelCacheRoot(options.saptoolsRoot);
|
|
3476
|
+
await mkdir4(root, { recursive: true, mode: 448 });
|
|
3477
|
+
const record = {
|
|
3478
|
+
version: 1,
|
|
3479
|
+
status: "establishing",
|
|
3480
|
+
host,
|
|
3481
|
+
startedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
3482
|
+
ownerPid,
|
|
3483
|
+
orgKey
|
|
3484
|
+
};
|
|
3485
|
+
try {
|
|
3486
|
+
await writeFile3(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
|
|
3487
|
+
`, {
|
|
3488
|
+
encoding: "utf8",
|
|
3489
|
+
mode: 384,
|
|
3490
|
+
flag: "wx"
|
|
3491
|
+
});
|
|
3492
|
+
return true;
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
if (isRecord3(error) && error["code"] === "EEXIST") {
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3497
|
+
throw error;
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
|
|
3501
|
+
if (await writeEstablishingMarker(host, ownerPid, orgKey, options)) {
|
|
3502
|
+
return { outcome: "claimed" };
|
|
3503
|
+
}
|
|
3504
|
+
const existing = await readTunnelCacheEntry(host, options);
|
|
3505
|
+
if (existing === void 0) {
|
|
3506
|
+
return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
|
|
3507
|
+
}
|
|
3508
|
+
if (existing.status === "ready") {
|
|
3509
|
+
return { outcome: "already-ready", record: existing };
|
|
3510
|
+
}
|
|
3511
|
+
if (!isEstablishingStale(existing, options)) {
|
|
3512
|
+
return { outcome: "wait" };
|
|
3513
|
+
}
|
|
3514
|
+
await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3515
|
+
return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
|
|
3516
|
+
}
|
|
3517
|
+
async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
|
|
3518
|
+
for (; ; ) {
|
|
3519
|
+
const entry = await readTunnelCacheEntry(host, options);
|
|
3520
|
+
if (entry === void 0) {
|
|
3521
|
+
return void 0;
|
|
3522
|
+
}
|
|
3523
|
+
if (entry.status === "ready") {
|
|
3524
|
+
return entry;
|
|
3525
|
+
}
|
|
3526
|
+
const remainingMs = deadline - Date.now();
|
|
3527
|
+
if (remainingMs <= 0) {
|
|
3528
|
+
return void 0;
|
|
3529
|
+
}
|
|
3530
|
+
await sleep(Math.min(pollIntervalMs, remainingMs));
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
async function finalizeEstablishingReady(host, record, options = {}) {
|
|
3534
|
+
const root = tunnelCacheRoot(options.saptoolsRoot);
|
|
3535
|
+
const path = tunnelCachePath(host, options.saptoolsRoot);
|
|
3536
|
+
const tempPath = `${path}.tmp-${String(process.pid)}`;
|
|
3537
|
+
const stored = { version: 1, status: "ready", host, ...record };
|
|
3538
|
+
await mkdir4(root, { recursive: true, mode: 448 });
|
|
3539
|
+
await rm5(tempPath, { force: true });
|
|
3540
|
+
await writeFile3(tempPath, `${JSON.stringify(stored)}
|
|
3541
|
+
`, { encoding: "utf8", mode: 384 });
|
|
3542
|
+
await rename2(tempPath, path);
|
|
3543
|
+
}
|
|
3544
|
+
async function finalizeEstablishingFailed(host, options = {}) {
|
|
3545
|
+
await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3546
|
+
}
|
|
3547
|
+
async function evictTunnelCache(host, options = {}) {
|
|
3548
|
+
await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3549
|
+
}
|
|
3550
|
+
function sameOrg(a, b) {
|
|
3551
|
+
return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
|
|
3552
|
+
}
|
|
3553
|
+
async function reapOneFile(path, currentOrgKey, options) {
|
|
3554
|
+
const entry = await parseTunnelCacheFile(path);
|
|
3555
|
+
if (entry === void 0) {
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
const killProcess = options.killProcess ?? killTunnelProcess;
|
|
3559
|
+
if (entry.status === "establishing") {
|
|
3560
|
+
if (isEstablishingStale(entry, options)) {
|
|
3561
|
+
await rm5(path, { force: true });
|
|
3562
|
+
}
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
3566
|
+
if (!isAlive(entry.pid)) {
|
|
3567
|
+
await rm5(path, { force: true });
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
3571
|
+
if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
|
|
3572
|
+
killProcess(entry.pid);
|
|
3573
|
+
await rm5(path, { force: true });
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
|
|
3577
|
+
const root = tunnelCacheRoot(options.saptoolsRoot);
|
|
3578
|
+
let files;
|
|
3579
|
+
try {
|
|
3580
|
+
files = await readdir2(root);
|
|
3581
|
+
} catch {
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
await Promise.all(
|
|
3585
|
+
files.filter((file) => file.endsWith(".json")).map(async (file) => {
|
|
3586
|
+
await reapOneFile(join6(root, file), currentOrgKey, options);
|
|
3587
|
+
})
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
// src/tunnel/candidates.ts
|
|
3592
|
+
var RUNNING_STATE = "started";
|
|
3593
|
+
var VALID_APP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
3594
|
+
function isValidAppName(name) {
|
|
3595
|
+
return VALID_APP_NAME.test(name);
|
|
3596
|
+
}
|
|
3597
|
+
function discoveredAppNames(stdout, targetAppName) {
|
|
3598
|
+
if (stdout === void 0) {
|
|
3599
|
+
return [];
|
|
3600
|
+
}
|
|
3601
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3602
|
+
const names = [];
|
|
3603
|
+
for (const row of parseCfAppsOutput(stdout)) {
|
|
3604
|
+
if (row.state !== RUNNING_STATE) {
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
if (row.name === targetAppName || seen.has(row.name)) {
|
|
3608
|
+
continue;
|
|
3609
|
+
}
|
|
3610
|
+
if (!isValidAppName(row.name)) {
|
|
3611
|
+
continue;
|
|
3612
|
+
}
|
|
3613
|
+
seen.add(row.name);
|
|
3614
|
+
names.push(row.name);
|
|
3615
|
+
}
|
|
3616
|
+
return names;
|
|
3617
|
+
}
|
|
3618
|
+
function buildCandidateList(targetAppName, discoveredStdout, maxCandidates) {
|
|
3619
|
+
const discovered = discoveredAppNames(discoveredStdout, targetAppName);
|
|
3620
|
+
return [targetAppName, ...discovered.slice(0, maxCandidates)];
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
// src/tunnel/classifier.ts
|
|
3624
|
+
var CONNECT_TIMEOUT_MESSAGE = "HANA connection timed out";
|
|
3625
|
+
var OPEN_CONNECTION_CODE = "EHDBOPENCONN";
|
|
3626
|
+
var MAX_CAUSE_DEPTH = 5;
|
|
3627
|
+
function causeCode(value) {
|
|
3628
|
+
return typeof value === "object" && value !== null ? value.code : void 0;
|
|
3629
|
+
}
|
|
3630
|
+
function causeOf(value) {
|
|
3631
|
+
return typeof value === "object" && value !== null ? value.cause : void 0;
|
|
3632
|
+
}
|
|
3633
|
+
function hasOpenConnectionCause(cause, depth) {
|
|
3634
|
+
if (depth > MAX_CAUSE_DEPTH) {
|
|
3635
|
+
return false;
|
|
3636
|
+
}
|
|
3637
|
+
if (causeCode(cause) === OPEN_CONNECTION_CODE) {
|
|
3638
|
+
return true;
|
|
3639
|
+
}
|
|
3640
|
+
return hasOpenConnectionCause(causeOf(cause), depth + 1);
|
|
3641
|
+
}
|
|
3642
|
+
function isConnectivityFailure(error) {
|
|
3643
|
+
if (!(error instanceof CfHanaError)) {
|
|
3644
|
+
return false;
|
|
3645
|
+
}
|
|
3646
|
+
if (error.code === "TIMEOUT") {
|
|
3647
|
+
return error.message.includes(CONNECT_TIMEOUT_MESSAGE);
|
|
3648
|
+
}
|
|
3649
|
+
if (error.code !== "CONNECTION") {
|
|
3650
|
+
return false;
|
|
3651
|
+
}
|
|
3652
|
+
return hasOpenConnectionCause(error.cause, 0);
|
|
3653
|
+
}
|
|
3654
|
+
function isUnattributedQueryFailure(error) {
|
|
3655
|
+
return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
// src/tunnel/fallback.ts
|
|
3659
|
+
var EXPIRY_SAFETY_MARGIN_MS = 3e4;
|
|
3660
|
+
function directParamsOf(config) {
|
|
3661
|
+
return {
|
|
3662
|
+
host: config.host,
|
|
3663
|
+
port: config.port,
|
|
3664
|
+
user: config.user,
|
|
3665
|
+
password: config.password,
|
|
3666
|
+
schema: config.schema,
|
|
3667
|
+
certificate: config.certificate,
|
|
3668
|
+
connectTimeoutMs: config.connectTimeoutMs
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
function tunneledParams(directParams, localPort) {
|
|
3672
|
+
return { ...directParams, host: "127.0.0.1", port: localPort, servername: directParams.host };
|
|
3673
|
+
}
|
|
3674
|
+
function orgKeyOf(config) {
|
|
3675
|
+
return { apiEndpoint: config.apiEndpoint, orgName: config.orgName };
|
|
3676
|
+
}
|
|
3677
|
+
function tunnelExpiryIso() {
|
|
3678
|
+
return new Date(
|
|
3679
|
+
Date.now() + DEFAULT_TUNNEL_KEEPALIVE_SECONDS * 1e3 - EXPIRY_SAFETY_MARGIN_MS
|
|
3680
|
+
).toISOString();
|
|
3681
|
+
}
|
|
3682
|
+
async function discardQuietly(work) {
|
|
3683
|
+
await work.catch(() => {
|
|
3684
|
+
});
|
|
3685
|
+
}
|
|
3686
|
+
function shouldDiscardTunnel(error) {
|
|
3687
|
+
return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
|
|
3688
|
+
}
|
|
3689
|
+
async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
|
|
3690
|
+
try {
|
|
3691
|
+
const connection = await driver.connect(tunneledParams(directParams, record.localPort));
|
|
3692
|
+
config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
|
|
3693
|
+
return connection;
|
|
3694
|
+
} catch (error) {
|
|
3695
|
+
if (shouldDiscardTunnel(error)) {
|
|
3696
|
+
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3697
|
+
return void 0;
|
|
3698
|
+
}
|
|
3699
|
+
throw error;
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
function knownCandidates(targetAppName, hintApp) {
|
|
3703
|
+
return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
|
|
3704
|
+
}
|
|
3705
|
+
async function discoverAppsStdout(ctx, timeoutMs) {
|
|
3706
|
+
try {
|
|
3707
|
+
return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
|
|
3708
|
+
} catch {
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
|
|
3713
|
+
const remainingMs = deadline - Date.now();
|
|
3714
|
+
if (remainingMs <= 0) {
|
|
3715
|
+
return [];
|
|
3716
|
+
}
|
|
3717
|
+
const stdout = await discoverAppsStdout(ctx, remainingMs);
|
|
3718
|
+
const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
|
|
3719
|
+
return base.filter((app) => !alreadyTried.includes(app));
|
|
3720
|
+
}
|
|
3721
|
+
async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
|
|
3722
|
+
const readyRecord = {
|
|
3723
|
+
localPort: spawned.localPort,
|
|
3724
|
+
pid: spawned.pid,
|
|
3725
|
+
app,
|
|
3726
|
+
orgKey: orgKeyOf(config),
|
|
3727
|
+
expiresAt: tunnelExpiryIso()
|
|
3728
|
+
};
|
|
3729
|
+
try {
|
|
3730
|
+
const connection = await driver.connect(tunneledParams(directParams, spawned.localPort));
|
|
3731
|
+
await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
|
|
3732
|
+
config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
|
|
3733
|
+
return connection;
|
|
3734
|
+
} catch (error) {
|
|
3735
|
+
if (shouldDiscardTunnel(error)) {
|
|
3736
|
+
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
3737
|
+
killTunnelProcess(spawned.pid);
|
|
3738
|
+
return void 0;
|
|
3739
|
+
}
|
|
3740
|
+
await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
|
|
3741
|
+
throw error;
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
async function tryCandidate(app, ctx, driver, config, directParams, deadline, overrides) {
|
|
3745
|
+
const cacheOptions = overrides.cache ?? {};
|
|
3746
|
+
config.onTunnelStatus?.(`trying to reach ${config.host} via app ${app}...`);
|
|
3747
|
+
const claim = await claimEstablishing(config.host, process.pid, orgKeyOf(config), cacheOptions);
|
|
3748
|
+
if (claim.outcome === "already-ready") {
|
|
3749
|
+
return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
|
|
3750
|
+
}
|
|
3751
|
+
if (claim.outcome === "wait") {
|
|
3752
|
+
const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
|
|
3753
|
+
const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
|
|
3754
|
+
return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
|
|
3755
|
+
}
|
|
3756
|
+
const spawned = await spawnTunnel(
|
|
3757
|
+
{
|
|
3758
|
+
cfHome: ctx?.cfHome,
|
|
3759
|
+
app,
|
|
3760
|
+
hanaHost: config.host,
|
|
3761
|
+
hanaPort: config.port,
|
|
3762
|
+
keepaliveSeconds: DEFAULT_TUNNEL_KEEPALIVE_SECONDS,
|
|
3763
|
+
deadline,
|
|
3764
|
+
candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
|
|
3765
|
+
},
|
|
3766
|
+
{
|
|
3767
|
+
...overrides.process,
|
|
3768
|
+
onFailureDiagnostic: (message) => {
|
|
3769
|
+
config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
);
|
|
3773
|
+
if (spawned === void 0) {
|
|
3774
|
+
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
3775
|
+
return void 0;
|
|
3776
|
+
}
|
|
3777
|
+
return await finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions);
|
|
3778
|
+
}
|
|
3779
|
+
async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
|
|
3780
|
+
if (config.refreshTunnel) {
|
|
3781
|
+
const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
3782
|
+
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3783
|
+
if (superseded?.status === "ready") {
|
|
3784
|
+
(cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
|
|
3785
|
+
}
|
|
3786
|
+
return { connection: void 0, hintApp: void 0 };
|
|
3787
|
+
}
|
|
3788
|
+
const entry = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
3789
|
+
if (entry?.status !== "ready") {
|
|
3790
|
+
return { connection: void 0, hintApp: void 0 };
|
|
3791
|
+
}
|
|
3792
|
+
if (!await isTunnelUsable(entry, cacheOptions)) {
|
|
3793
|
+
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3794
|
+
return { connection: void 0, hintApp: entry.app };
|
|
3795
|
+
}
|
|
3796
|
+
const connection = await tryReadyRecord(entry, driver, config, directParams, cacheOptions);
|
|
3797
|
+
return { connection, hintApp: entry.app };
|
|
3798
|
+
}
|
|
3799
|
+
async function tryDirectConnect(driver, config, directParams) {
|
|
3800
|
+
if (config.tunnelMode !== "auto") {
|
|
3801
|
+
return { connection: void 0, directError: void 0 };
|
|
3802
|
+
}
|
|
3803
|
+
const boundedDirectParams = {
|
|
3804
|
+
...directParams,
|
|
3805
|
+
connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
|
|
3806
|
+
};
|
|
3807
|
+
try {
|
|
3808
|
+
return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
|
|
3809
|
+
} catch (error) {
|
|
3810
|
+
if (!isConnectivityFailure(error)) {
|
|
3811
|
+
throw error;
|
|
3812
|
+
}
|
|
3813
|
+
return { connection: void 0, directError: error };
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
function buildExhaustionError(host, directError, candidates) {
|
|
3817
|
+
if (directError !== void 0) {
|
|
3818
|
+
return directError;
|
|
3819
|
+
}
|
|
3820
|
+
return new CfHanaError(
|
|
3821
|
+
"CONNECTION",
|
|
3822
|
+
`Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3825
|
+
async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
|
|
3826
|
+
for (const app of apps) {
|
|
3827
|
+
if (Date.now() >= deadline) {
|
|
3828
|
+
return void 0;
|
|
3829
|
+
}
|
|
3830
|
+
tried.push(app);
|
|
3831
|
+
const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
|
|
3832
|
+
if (connection !== void 0) {
|
|
3833
|
+
return connection;
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
return void 0;
|
|
3837
|
+
}
|
|
3838
|
+
async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
|
|
3839
|
+
const tried = [];
|
|
3840
|
+
const known = knownCandidates(config.appName, hintApp);
|
|
3841
|
+
const knownConnection = await tryCandidateList(
|
|
3842
|
+
known,
|
|
3843
|
+
ctx,
|
|
3844
|
+
driver,
|
|
3845
|
+
config,
|
|
3846
|
+
directParams,
|
|
3847
|
+
deadline,
|
|
3848
|
+
overrides,
|
|
3849
|
+
tried
|
|
3850
|
+
);
|
|
3851
|
+
if (knownConnection !== void 0) {
|
|
3852
|
+
return { connection: knownConnection, candidatesTried: tried };
|
|
3853
|
+
}
|
|
3854
|
+
const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
|
|
3855
|
+
const discoveredConnection = await tryCandidateList(
|
|
3856
|
+
discovered,
|
|
3857
|
+
ctx,
|
|
3858
|
+
driver,
|
|
3859
|
+
config,
|
|
3860
|
+
directParams,
|
|
3861
|
+
deadline,
|
|
3862
|
+
overrides,
|
|
3863
|
+
tried
|
|
3864
|
+
);
|
|
3865
|
+
return { connection: discoveredConnection, candidatesTried: tried };
|
|
3866
|
+
}
|
|
3867
|
+
async function connectWithTunnelFallback(driver, config, overrides = {}) {
|
|
3868
|
+
const directParams = directParamsOf(config);
|
|
3869
|
+
const cacheOptions = overrides.cache ?? {};
|
|
3870
|
+
await discardQuietly(reapStaleAndCrossOrgTunnels(orgKeyOf(config), cacheOptions));
|
|
3871
|
+
const cached = await tryCachedTunnel(driver, config, directParams, cacheOptions);
|
|
3872
|
+
if (cached.connection !== void 0) {
|
|
3873
|
+
return cached.connection;
|
|
3874
|
+
}
|
|
3875
|
+
const direct = await tryDirectConnect(driver, config, directParams);
|
|
3876
|
+
if (direct.connection !== void 0) {
|
|
3877
|
+
return direct.connection;
|
|
3878
|
+
}
|
|
3879
|
+
const deadline = Date.now() + DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
|
|
3880
|
+
const target = {
|
|
3881
|
+
apiEndpoint: config.apiEndpoint,
|
|
3882
|
+
orgName: config.orgName,
|
|
3883
|
+
spaceName: config.spaceName
|
|
3884
|
+
};
|
|
3885
|
+
const loopResult = await withScopedCfSession(
|
|
3886
|
+
config.selectorSource,
|
|
3887
|
+
target,
|
|
3888
|
+
config.sapCredentials,
|
|
3889
|
+
(ctx) => runCandidateLoop(ctx, driver, config, directParams, deadline, cached.hintApp, overrides)
|
|
3890
|
+
);
|
|
3891
|
+
if (loopResult.connection !== void 0) {
|
|
3892
|
+
return loopResult.connection;
|
|
3893
|
+
}
|
|
3894
|
+
throw buildExhaustionError(config.host, direct.directError, loopResult.candidatesTried);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
2998
3897
|
// src/connection.ts
|
|
2999
3898
|
function assertValidAutoLimit(value) {
|
|
3000
3899
|
if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
|
|
@@ -3088,15 +3987,7 @@ var Connection = class _Connection {
|
|
|
3088
3987
|
driverConnection;
|
|
3089
3988
|
config;
|
|
3090
3989
|
static async open(driver, config) {
|
|
3091
|
-
const driverConnection = await driver
|
|
3092
|
-
host: config.host,
|
|
3093
|
-
port: config.port,
|
|
3094
|
-
user: config.user,
|
|
3095
|
-
password: config.password,
|
|
3096
|
-
schema: config.schema,
|
|
3097
|
-
certificate: config.certificate,
|
|
3098
|
-
connectTimeoutMs: config.connectTimeoutMs
|
|
3099
|
-
});
|
|
3990
|
+
const driverConnection = await connectWithTunnelFallback(driver, config);
|
|
3100
3991
|
return new _Connection(driverConnection, config);
|
|
3101
3992
|
}
|
|
3102
3993
|
async query(sql, params, options) {
|
|
@@ -3378,6 +4269,31 @@ function toPoolOptions(options) {
|
|
|
3378
4269
|
}
|
|
3379
4270
|
return options.pool ?? {};
|
|
3380
4271
|
}
|
|
4272
|
+
function buildConnectionConfig(resolved, target, options) {
|
|
4273
|
+
const sapCredentials = readSapCredentials({ email: options.email, password: options.password });
|
|
4274
|
+
return {
|
|
4275
|
+
host: target.host,
|
|
4276
|
+
port: target.port,
|
|
4277
|
+
user: target.user,
|
|
4278
|
+
password: target.password,
|
|
4279
|
+
schema: target.schema,
|
|
4280
|
+
certificate: target.certificate,
|
|
4281
|
+
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
4282
|
+
queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
4283
|
+
readOnly: options.readOnly ?? false,
|
|
4284
|
+
allowDestructive: options.allowDestructive ?? false,
|
|
4285
|
+
autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT,
|
|
4286
|
+
appName: resolved.appName,
|
|
4287
|
+
orgName: resolved.orgName,
|
|
4288
|
+
spaceName: resolved.spaceName,
|
|
4289
|
+
apiEndpoint: resolved.apiEndpoint,
|
|
4290
|
+
selectorSource: resolved.selectorSource,
|
|
4291
|
+
tunnelMode: options.tunnel === true ? "always" : "auto",
|
|
4292
|
+
refreshTunnel: options.refreshTunnel ?? false,
|
|
4293
|
+
...sapCredentials === void 0 ? {} : { sapCredentials },
|
|
4294
|
+
...options.onTunnelStatus === void 0 ? {} : { onTunnelStatus: options.onTunnelStatus }
|
|
4295
|
+
};
|
|
4296
|
+
}
|
|
3381
4297
|
function toSelectedBindingInfo(bindings, selected) {
|
|
3382
4298
|
const availableBindingNames = bindings.flatMap(
|
|
3383
4299
|
(binding) => binding.name === void 0 ? [] : [binding.name]
|
|
@@ -3405,19 +4321,7 @@ var HanaClient = class _HanaClient {
|
|
|
3405
4321
|
const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
|
|
3406
4322
|
const target = toConnectionTarget(binding, role);
|
|
3407
4323
|
const driver = createDriver();
|
|
3408
|
-
const config =
|
|
3409
|
-
host: target.host,
|
|
3410
|
-
port: target.port,
|
|
3411
|
-
user: target.user,
|
|
3412
|
-
password: target.password,
|
|
3413
|
-
schema: target.schema,
|
|
3414
|
-
certificate: target.certificate,
|
|
3415
|
-
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
3416
|
-
queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
3417
|
-
readOnly: options.readOnly ?? false,
|
|
3418
|
-
allowDestructive: options.allowDestructive ?? false,
|
|
3419
|
-
autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
|
|
3420
|
-
};
|
|
4324
|
+
const config = buildConnectionConfig(resolved, target, options);
|
|
3421
4325
|
const poolOptions = toPoolOptions(options);
|
|
3422
4326
|
const info = {
|
|
3423
4327
|
selector: resolved.selector,
|
|
@@ -3614,7 +4518,7 @@ async function connect(selector, options) {
|
|
|
3614
4518
|
}
|
|
3615
4519
|
|
|
3616
4520
|
// src/cli-results.ts
|
|
3617
|
-
import { writeFile as
|
|
4521
|
+
import { writeFile as writeFile5 } from "fs/promises";
|
|
3618
4522
|
|
|
3619
4523
|
// src/result-inspect.ts
|
|
3620
4524
|
function assertPositiveInteger(name, value) {
|
|
@@ -3886,19 +4790,19 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
3886
4790
|
|
|
3887
4791
|
// src/result-store.ts
|
|
3888
4792
|
import { randomBytes } from "crypto";
|
|
3889
|
-
import { mkdir as
|
|
3890
|
-
import { homedir as
|
|
3891
|
-
import { join as
|
|
4793
|
+
import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
|
|
4794
|
+
import { homedir as homedir5 } from "os";
|
|
4795
|
+
import { join as join7 } from "path";
|
|
3892
4796
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
3893
4797
|
var MANIFEST_FILE_NAME = "manifest.json";
|
|
3894
4798
|
function resultsRoot(saptoolsRoot) {
|
|
3895
|
-
return
|
|
4799
|
+
return join7(saptoolsRoot ?? join7(homedir5(), ".saptools"), "cf-hana", "results");
|
|
3896
4800
|
}
|
|
3897
4801
|
function sessionDirectory(ref, saptoolsRoot) {
|
|
3898
|
-
return
|
|
4802
|
+
return join7(resultsRoot(saptoolsRoot), ref);
|
|
3899
4803
|
}
|
|
3900
4804
|
function manifestPath(ref, saptoolsRoot) {
|
|
3901
|
-
return
|
|
4805
|
+
return join7(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
3902
4806
|
}
|
|
3903
4807
|
function encodeCell(value) {
|
|
3904
4808
|
if (value === null) {
|
|
@@ -3997,11 +4901,11 @@ function toStoredSession(input, ref, now) {
|
|
|
3997
4901
|
result: encodeResult(input.result)
|
|
3998
4902
|
};
|
|
3999
4903
|
}
|
|
4000
|
-
function
|
|
4904
|
+
function isRecord4(value) {
|
|
4001
4905
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4002
4906
|
}
|
|
4003
4907
|
function isStoredSession(value) {
|
|
4004
|
-
if (!
|
|
4908
|
+
if (!isRecord4(value) || !isRecord4(value["result"]) || !isRecord4(value["info"])) {
|
|
4005
4909
|
return false;
|
|
4006
4910
|
}
|
|
4007
4911
|
const result = value["result"];
|
|
@@ -4009,7 +4913,7 @@ function isStoredSession(value) {
|
|
|
4009
4913
|
}
|
|
4010
4914
|
async function readStoredSession(path) {
|
|
4011
4915
|
try {
|
|
4012
|
-
const parsed = JSON.parse(await
|
|
4916
|
+
const parsed = JSON.parse(await readFile4(path, "utf8"));
|
|
4013
4917
|
return isStoredSession(parsed) ? parsed : void 0;
|
|
4014
4918
|
} catch (error) {
|
|
4015
4919
|
if (error.code === "ENOENT") {
|
|
@@ -4028,7 +4932,7 @@ function toSession(stored, saptoolsRoot) {
|
|
|
4028
4932
|
}
|
|
4029
4933
|
async function listSessionRefs(saptoolsRoot) {
|
|
4030
4934
|
try {
|
|
4031
|
-
const entries = await
|
|
4935
|
+
const entries = await readdir3(resultsRoot(saptoolsRoot), { withFileTypes: true });
|
|
4032
4936
|
return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
|
|
4033
4937
|
} catch (error) {
|
|
4034
4938
|
if (error.code === "ENOENT") {
|
|
@@ -4049,17 +4953,17 @@ async function createResultSession(input, options = {}) {
|
|
|
4049
4953
|
const root = resultsRoot(options.saptoolsRoot);
|
|
4050
4954
|
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
4051
4955
|
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
4052
|
-
await
|
|
4053
|
-
await
|
|
4054
|
-
await
|
|
4956
|
+
await mkdir5(root, { recursive: true, mode: 448 });
|
|
4957
|
+
await rm6(tempDirectory, { recursive: true, force: true });
|
|
4958
|
+
await mkdir5(tempDirectory, { mode: 448 });
|
|
4055
4959
|
try {
|
|
4056
|
-
await
|
|
4960
|
+
await writeFile4(join7(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
4057
4961
|
encoding: "utf8",
|
|
4058
4962
|
mode: 384
|
|
4059
4963
|
});
|
|
4060
|
-
await
|
|
4964
|
+
await rename3(tempDirectory, finalDirectory);
|
|
4061
4965
|
} catch (error) {
|
|
4062
|
-
await
|
|
4966
|
+
await rm6(tempDirectory, { recursive: true, force: true });
|
|
4063
4967
|
throw error;
|
|
4064
4968
|
}
|
|
4065
4969
|
return toSession(stored, options.saptoolsRoot);
|
|
@@ -4102,7 +5006,7 @@ async function pruneResultSessions(options = {}) {
|
|
|
4102
5006
|
for (const ref of refs) {
|
|
4103
5007
|
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
4104
5008
|
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
4105
|
-
await
|
|
5009
|
+
await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
4106
5010
|
removed += 1;
|
|
4107
5011
|
}
|
|
4108
5012
|
}
|
|
@@ -4112,7 +5016,7 @@ async function clearResultSessions(options = {}) {
|
|
|
4112
5016
|
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
4113
5017
|
await Promise.all(
|
|
4114
5018
|
refs.map(async (ref) => {
|
|
4115
|
-
await
|
|
5019
|
+
await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
4116
5020
|
})
|
|
4117
5021
|
);
|
|
4118
5022
|
return refs.length;
|
|
@@ -4282,7 +5186,7 @@ async function runExport(ref, options) {
|
|
|
4282
5186
|
}
|
|
4283
5187
|
const session = await readResultSession(ref);
|
|
4284
5188
|
const selected = selectResultCell(session, options.row, options.column);
|
|
4285
|
-
await
|
|
5189
|
+
await writeFile5(options.output, cellExportValue(selected.value, selected.typeName), {
|
|
4286
5190
|
mode: 384
|
|
4287
5191
|
});
|
|
4288
5192
|
print(`wrote=${options.output}`);
|
|
@@ -4350,8 +5254,12 @@ function printResolvedTarget(info) {
|
|
|
4350
5254
|
`
|
|
4351
5255
|
);
|
|
4352
5256
|
}
|
|
5257
|
+
function printTunnelStatus(message) {
|
|
5258
|
+
process.stderr.write(`${CLI_NAME}: ${message}
|
|
5259
|
+
`);
|
|
5260
|
+
}
|
|
4353
5261
|
async function connectForCli(selector, options) {
|
|
4354
|
-
const client = await connect(selector, options);
|
|
5262
|
+
const client = await connect(selector, { ...options, onTunnelStatus: printTunnelStatus });
|
|
4355
5263
|
printResolvedTarget(client.info);
|
|
4356
5264
|
return client;
|
|
4357
5265
|
}
|
|
@@ -4416,6 +5324,8 @@ function toConnectOptions(opts) {
|
|
|
4416
5324
|
readOnly: opts.readOnly,
|
|
4417
5325
|
allowDestructive: opts.allowDestructive,
|
|
4418
5326
|
autoLimit: opts.autoLimit ? opts.limit ?? DEFAULT_AUTO_LIMIT : false,
|
|
5327
|
+
tunnel: opts.tunnel,
|
|
5328
|
+
refreshTunnel: opts.refreshTunnel,
|
|
4419
5329
|
...opts.binding === void 0 ? {} : { bindingName: opts.binding },
|
|
4420
5330
|
...opts.bindingIndex === void 0 ? {} : { bindingIndex: opts.bindingIndex },
|
|
4421
5331
|
...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
|
|
@@ -4512,7 +5422,15 @@ function formatInfo(info) {
|
|
|
4512
5422
|
].join("\n");
|
|
4513
5423
|
}
|
|
4514
5424
|
function withConnectionOptions(command) {
|
|
4515
|
-
return command.option("--refresh", "deprecated compatibility flag; binding discovery is already live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false)
|
|
5425
|
+
return command.option("--refresh", "deprecated compatibility flag; binding discovery is already live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false).option(
|
|
5426
|
+
"--tunnel",
|
|
5427
|
+
"skip the direct connection attempt and connect via an SSH tunnel immediately",
|
|
5428
|
+
false
|
|
5429
|
+
).option(
|
|
5430
|
+
"--refresh-tunnel",
|
|
5431
|
+
"bypass a cached/live SSH tunnel and force a fresh establishment attempt",
|
|
5432
|
+
false
|
|
5433
|
+
);
|
|
4516
5434
|
}
|
|
4517
5435
|
function withFormattedConnectionOptions(command) {
|
|
4518
5436
|
return withConnectionOptions(command).option(
|