-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathcliOperations.ts
More file actions
333 lines (311 loc) · 12.1 KB
/
Copy pathcliOperations.ts
File metadata and controls
333 lines (311 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
'use strict';
import { loadCredentials, saveCredentials, normalizeTarget } from './cliCredentials.ts';
import { isJWTExpired } from '../security/tokenAuthentication.ts';
import * as envMgr from '../utility/environment/environmentManager.ts';
envMgr.initSync();
import * as terms from '../utility/hdbTerms.ts';
import { httpRequest } from '../utility/common_utils.ts';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as YAML from 'yaml';
import { streamPackagedDirectory, getPackagedDirectorySize } from '../components/packageComponent.ts';
import { buildMultipartBody } from './multipartBuilder.ts';
import { parseSSE } from './sseConsumer.ts';
import { DeployRenderer } from './deployRenderer.ts';
import { getHdbPid } from '../utility/processManagement/processManagement.js';
import { initConfig, getConfigPath } from '../config/configUtils.js';
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
// Operations whose responses should be consumed as text/event-stream so live phase events
// (extract, install, load, replicate, restart) render as they happen instead of after the
// whole deploy completes. Add an operation here only after wiring its server-side
// SSE_PROGRESS_OPERATIONS entry — otherwise the server returns the buffered JSON path and
// the SSE parser sees no events.
const SSE_OPERATIONS = new Set(['deploy_component']);
// Properties on `req` that the CLI itself uses for transport/UX, not the operations API.
// They never get serialized into the request body.
const TRANSPORT_ONLY_FIELDS = new Set([
'target',
'username',
'password',
'rejectUnauthorized',
'json',
'skip_node_modules',
'skip_symlinks',
]);
export { cliOperations, buildRequest };
const PREPARE_OPERATION: any = {
deploy_component: async (req) => {
if (req.package) {
return;
}
const projectPath = process.cwd();
if (!req.project) req.project = path.basename(projectPath);
const pkgOptions = {
skip_node_modules: req.skip_node_modules !== false,
skip_symlinks: req.skip_symlinks === true,
};
// Compute the uncompressed source-tree total up front so the upload bar has a
// meaningful 100% target. Done before streaming begins; for very large trees this
// adds a one-time directory walk that's still much cheaper than the deploy itself.
// Best-effort: getPackagedDirectorySize swallows per-entry stat errors and returns
// whatever it could measure, so a permission glitch can't block the deploy.
req._uploadTotal = await getPackagedDirectorySize(projectPath, pkgOptions);
// Stream the tar+gzip directly to the server as the file part of a multipart body.
// This bypasses the Node Buffer 2 GB cap that the previous CBOR-encoded path was
// subject to, so large components can deploy without materializing in memory.
req._packageStream = streamPackagedDirectory(projectPath, pkgOptions);
req._multipart = true;
},
};
/**
* Builds an Op-API request object from CLI args
*/
function buildRequest(): any {
const req: any = {};
for (const arg of process.argv.slice(2)) {
if (OP_ALIASES.hasOwnProperty(arg)) {
req.operation = OP_ALIASES[arg];
} else if (arg.includes('=')) {
let [first, ...rest] = arg.split('=');
let restStr: any = rest.join('=');
try {
restStr = JSON.parse(restStr);
} catch {
/* noop */
}
req[first] = restStr;
} else {
// operation should only be in the first arg
req.operation ??= arg;
}
}
return req;
}
/**
* Resolves the target URL from various sources.
* @param {Object} req The request object.
* @param {Object} allCredentials Stored credentials.
* @returns {string|null} The resolved target URL.
*/
function resolveTarget(req, allCredentials) {
return (
req.target ||
process.env.HARPER_CLI_TARGET ||
process.env.CLI_TARGET ||
(allCredentials && allCredentials.last_target)
);
}
/**
* Using a unix domain socket will send a request to hdb operations API server
* @param req
* @param skipResponseLog By default, the response is logged to the console. Set this to true to skip logging it, which can be useful for sensitive responses like login calls!
* @returns {Promise<void>}
*/
async function cliOperations(req: any, skipResponseLog = false) {
require('dotenv').config();
const allCredentials = loadCredentials();
req.target = normalizeTarget(resolveTarget(req, allCredentials));
let target;
if (req.target) {
try {
target = new URL(req.target);
} catch (error) {
try {
target = new URL(`https://${req.target}:9925`);
} catch {
throw error;
}
}
const resolvedTarget = req.target;
target = {
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
username: req.username || target.username || process.env.HARPER_CLI_USERNAME || process.env.CLI_TARGET_USERNAME,
password: req.password || target.password || process.env.HARPER_CLI_PASSWORD || process.env.CLI_TARGET_PASSWORD,
rejectUnauthorized: req.rejectUnauthorized,
resolvedTarget,
};
console.error(`Connecting to ${resolvedTarget}`);
} else {
// if we aren't doing a targeted operation (like deploy), we initialize the config and verify that local harper
// is running and that we can communicate with it.
console.error('Connecting to local Harper instance');
initConfig();
if (!getHdbPid()) {
console.error('Harper must be running to perform this operation');
process.exit(1);
}
if (!fs.existsSync(getConfigPath(terms.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_DOMAINSOCKET))) {
console.error('No domain socket found, unable to perform this operation');
process.exit(1);
}
}
await PREPARE_OPERATION[req.operation]?.(req);
try {
let options = target ?? {
protocol: 'http:',
socketPath: getConfigPath(terms.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_DOMAINSOCKET),
};
options.method = 'POST';
options.headers = { 'Content-Type': 'application/json' };
if (target?.username) {
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
} else if (allCredentials) {
let tokens = null;
let lookupKey = null;
if (target && allCredentials.targets) {
lookupKey = target.resolvedTarget;
tokens = allCredentials.targets[lookupKey] ?? null;
}
if (tokens?.operation_token) {
if (tokens.refresh_token && isJWTExpired(tokens.operation_token)) {
console.error('Operation token expired, attempting to refresh...');
try {
const refreshOptions = { ...options };
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
const refreshResponse = await httpRequest(refreshOptions, {
operation: 'refresh_operation_token',
});
if (refreshResponse.statusCode === 200) {
const refreshData = JSON.parse(refreshResponse.body);
if (refreshData.operation_token) {
tokens.operation_token = refreshData.operation_token;
saveCredentials(lookupKey || target?.resolvedTarget, {
operation_token: tokens.operation_token,
refresh_token: tokens.refresh_token,
});
console.error('Operation token refreshed successfully.');
// Update the original request's authorization header with the new token
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
}
} else if (refreshResponse.statusCode === 401) {
console.error('Refresh token expired or invalid. Please run harper login again.');
process.exit(1);
} else {
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`);
}
} catch (refreshErr) {
console.error(`Error refreshing operation token: ${refreshErr.message}`);
}
}
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
}
}
const useSse = SSE_OPERATIONS.has(req.operation);
if (useSse) {
options.headers.Accept = 'text/event-stream';
options.streamResponse = true;
}
let body;
// One renderer owns the upload bar and the SSE event rendering for a multipart deploy.
// Created here so the upload-stream tap and the SSE consumer below see the same instance.
const renderer = req._multipart ? new DeployRenderer({ uploadTotal: req._uploadTotal }) : null;
if (req._multipart) {
const packageStream = req._packageStream;
const fields = {};
for (const [key, value] of Object.entries(req)) {
if (key.startsWith('_') || TRANSPORT_ONLY_FIELDS.has(key)) continue;
fields[key] = value;
}
const multipart = buildMultipartBody(
fields,
packageStream
? { name: 'payload', filename: 'package.tar.gz', contentType: 'application/gzip', stream: packageStream }
: undefined
);
options.headers['Content-Type'] = multipart.contentType;
// Use chunked transfer-encoding: we don't know the total size up front because the
// payload is streamed from `tar.pack` and never fully buffered.
options.headers['Transfer-Encoding'] = 'chunked';
// Tap the body so bytes flowing into the HTTP request advance the upload bar.
// The renderer's Transform is identity — chunks pass through unmodified.
body = renderer ? renderer.tapUploadStream(multipart.stream) : multipart.stream;
} else {
body = req;
}
let response: any = await httpRequest(options, body);
// Upload is done by the time we get the response; tear the bar down before any SSE
// rendering so the bar and event lines don't fight for the same terminal row.
renderer?.endUpload();
let responseData;
if (useSse && response.headers['content-type']?.startsWith('text/event-stream')) {
// Consume SSE: render phase events live, capture the final result from the `done`
// event (or the error message from the `error` event). The HTTP status stays 200
// until end-of-stream; failures are signaled in-band.
let finalResult;
let sseError;
for await (const message of parseSSE(response)) {
renderer?.renderEvent(message);
if (message.event === 'done') {
try {
finalResult = JSON.parse(message.data)?.result;
} catch {
finalResult = message.data;
}
} else if (message.event === 'error') {
try {
sseError = JSON.parse(message.data);
} catch {
sseError = { message: message.data };
}
}
}
if (sseError) {
const errMsg = sseError.message ?? (typeof sseError === 'object' ? JSON.stringify(sseError) : sseError);
console.error(`error: ${errMsg}`);
process.exit(1);
}
responseData = finalResult ?? { message: 'Deploy completed (no result payload).' };
} else {
// When useSse is true, httpRequest returns a raw IncomingMessage (streamResponse mode),
// so .body is undefined. Drain the stream to get the text (e.g. a 401 error body).
let bodyText: string;
if (useSse) {
const chunks: Buffer[] = [];
for await (const chunk of response as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk));
bodyText = Buffer.concat(chunks).toString('utf8');
} else {
bodyText = response.body;
}
try {
responseData = JSON.parse(bodyText);
} catch {
responseData = {
status: response.statusCode + ' ' + (response.statusMessage || 'Unknown'),
body: bodyText,
};
}
}
let responseLog;
if (req.json) {
responseLog = JSON.stringify(responseData, null, 2);
} else {
responseLog = YAML.stringify(responseData).trim();
}
const { statusCode } = response;
if (statusCode < 200 || (statusCode >= 300 && statusCode !== 304)) {
const errorPrefix = responseLog.startsWith('error:') ? '' : 'error: ';
console.error(`${errorPrefix}${responseLog}`);
process.exit(1);
}
if (!skipResponseLog) {
console.log(responseLog);
}
if (target) {
responseData.resolvedTarget = target.resolvedTarget;
}
return responseData;
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
console.error(`error: Failed to connect to Harper (${err.code}): ${err.message}`);
} else if (err.code === 'EACCES') {
console.error(`error: Permission denied accessing the domain socket: ${err.message}`);
} else if (err.code === 'ENOTFOUND') {
console.error(`error: Host not found: "${err.hostname}" ${err.message}`);
} else {
console.error(`error: ${err.message ?? err}`);
}
process.exit(1);
}
}