@malloy-publisher/server 0.0.92 → 0.0.93
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/dist/app/api-doc.yaml +36 -4
- package/dist/app/assets/{index-DYO_URL-.js → index-D1sQfWlS.js} +76 -76
- package/dist/app/assets/{index.es50-CDMydA2o.js → index.es56-C6ktttDI.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +14 -0
- package/dist/server.js +4596 -1230
- package/package.json +1 -1
- package/src/constants.ts +21 -5
- package/src/controller/connection.controller.ts +32 -3
- package/src/server.ts +14 -0
- package/src/service/connection.ts +260 -3
- package/src/service/project_store.spec.ts +9 -2
- package/src/service/project_store.ts +23 -23
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
2
4
|
export const API_PREFIX = "/api/v0";
|
|
3
5
|
export const README_NAME = "README.md";
|
|
4
6
|
export const PUBLISHER_CONFIG_NAME = "publisher.config.json";
|
|
@@ -7,10 +9,24 @@ export const CONNECTIONS_MANIFEST_NAME = "publisher.connections.json";
|
|
|
7
9
|
export const MODEL_FILE_SUFFIX = ".malloy";
|
|
8
10
|
export const NOTEBOOK_FILE_SUFFIX = ".malloynb";
|
|
9
11
|
export const ROW_LIMIT = 1000;
|
|
12
|
+
export const TEMP_DIR_PATH = os.tmpdir();
|
|
13
|
+
export let publisherPath: string;
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
publisherPath = path.join(
|
|
17
|
+
process.env.PROGRAMDATA || "C:\\ProgramData",
|
|
18
|
+
"publisher",
|
|
19
|
+
);
|
|
20
|
+
try {
|
|
21
|
+
fs.accessSync(publisherPath, fs.constants.W_OK);
|
|
22
|
+
} catch {
|
|
23
|
+
publisherPath = path.join(os.tmpdir(), "publisher");
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
publisherPath = "/etc/publisher";
|
|
27
|
+
try {
|
|
28
|
+
fs.accessSync(publisherPath, fs.constants.W_OK);
|
|
29
|
+
} catch {
|
|
30
|
+
publisherPath = path.join(os.tmpdir(), "publisher");
|
|
31
|
+
}
|
|
16
32
|
}
|
|
@@ -4,15 +4,15 @@ import {
|
|
|
4
4
|
TestableConnection,
|
|
5
5
|
} from "@malloydata/malloy";
|
|
6
6
|
import { Connection, PersistSQLResults } from "@malloydata/malloy/connection";
|
|
7
|
-
import {
|
|
8
|
-
import { ConnectionError } from "../errors";
|
|
7
|
+
import { BadRequestError, ConnectionError } from "../errors";
|
|
9
8
|
import { logger } from "../logger";
|
|
9
|
+
import { components } from "../api";
|
|
10
10
|
import {
|
|
11
11
|
getSchemasForConnection,
|
|
12
12
|
getTablesForSchema,
|
|
13
13
|
} from "../service/db_utils";
|
|
14
14
|
import { ProjectStore } from "../service/project_store";
|
|
15
|
-
|
|
15
|
+
import { testConnectionConfig } from "../service/connection";
|
|
16
16
|
type ApiConnection = components["schemas"]["Connection"];
|
|
17
17
|
type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
|
|
18
18
|
type ApiSqlSource = components["schemas"]["SqlSource"];
|
|
@@ -189,4 +189,33 @@ export class ConnectionController {
|
|
|
189
189
|
throw new ConnectionError((error as Error).message);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
+
|
|
193
|
+
public async testConnectionConfiguration(
|
|
194
|
+
connectionConfig: ApiConnection,
|
|
195
|
+
): Promise<ApiConnectionStatus> {
|
|
196
|
+
if (
|
|
197
|
+
!connectionConfig ||
|
|
198
|
+
typeof connectionConfig !== "object" ||
|
|
199
|
+
Object.keys(connectionConfig).length === 0
|
|
200
|
+
) {
|
|
201
|
+
throw new BadRequestError(
|
|
202
|
+
"Connection configuration is required and cannot be empty",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!connectionConfig.type || typeof connectionConfig.type !== "string") {
|
|
207
|
+
throw new BadRequestError(
|
|
208
|
+
"Connection type is required and must be a string",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return await testConnectionConfig(connectionConfig);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return {
|
|
216
|
+
status: "failed",
|
|
217
|
+
errorMessage: `Connection test failed: ${(error as Error).message}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
192
221
|
}
|
package/src/server.ts
CHANGED
|
@@ -311,6 +311,20 @@ app.get(
|
|
|
311
311
|
},
|
|
312
312
|
);
|
|
313
313
|
|
|
314
|
+
app.post(`${API_PREFIX}/connections/test`, async (req, res) => {
|
|
315
|
+
try {
|
|
316
|
+
const connectionStatus =
|
|
317
|
+
await connectionController.testConnectionConfiguration(
|
|
318
|
+
req.body.connectionConfig,
|
|
319
|
+
);
|
|
320
|
+
res.status(200).json(connectionStatus);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
logger.error(error);
|
|
323
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
324
|
+
res.status(status).json(json);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
314
328
|
app.get(
|
|
315
329
|
`${API_PREFIX}/projects/:projectName/connections/:connectionName/test`,
|
|
316
330
|
async (req, res) => {
|
|
@@ -4,16 +4,19 @@ import { PostgresConnection } from "@malloydata/db-postgres";
|
|
|
4
4
|
import { SnowflakeConnection } from "@malloydata/db-snowflake";
|
|
5
5
|
import { TrinoConnection } from "@malloydata/db-trino";
|
|
6
6
|
import { Connection } from "@malloydata/malloy";
|
|
7
|
+
import { BadRequestError } from "../errors";
|
|
8
|
+
import { logAxiosError, logger } from "../logger";
|
|
7
9
|
import { BaseConnection } from "@malloydata/malloy/connection";
|
|
8
10
|
import fs from "fs/promises";
|
|
9
11
|
import path from "path";
|
|
10
12
|
import { v4 as uuidv4 } from "uuid";
|
|
11
13
|
import { components } from "../api";
|
|
12
|
-
import { CONNECTIONS_MANIFEST_NAME } from "../constants";
|
|
13
|
-
import {
|
|
14
|
+
import { CONNECTIONS_MANIFEST_NAME, TEMP_DIR_PATH } from "../constants";
|
|
15
|
+
import { AxiosError } from "axios";
|
|
14
16
|
|
|
15
17
|
type ApiConnection = components["schemas"]["Connection"];
|
|
16
18
|
type ApiConnectionAttributes = components["schemas"]["ConnectionAttributes"];
|
|
19
|
+
type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
|
|
17
20
|
|
|
18
21
|
// Extends the public API connection with the internal connection objects
|
|
19
22
|
// which contains passwords and connection strings.
|
|
@@ -129,7 +132,7 @@ export async function createConnections(
|
|
|
129
132
|
let serviceAccountKeyPath = undefined;
|
|
130
133
|
if (connection.bigqueryConnection.serviceAccountKeyJson) {
|
|
131
134
|
serviceAccountKeyPath = path.join(
|
|
132
|
-
|
|
135
|
+
TEMP_DIR_PATH,
|
|
133
136
|
`${connection.name}-${uuidv4()}-service-account-key.json`,
|
|
134
137
|
);
|
|
135
138
|
await fs.writeFile(
|
|
@@ -255,3 +258,257 @@ function getConnectionAttributes(
|
|
|
255
258
|
canStream: canStream,
|
|
256
259
|
};
|
|
257
260
|
}
|
|
261
|
+
|
|
262
|
+
export async function testConnectionConfig(
|
|
263
|
+
connectionConfig: ApiConnection,
|
|
264
|
+
): Promise<ApiConnectionStatus> {
|
|
265
|
+
let testResult: { status: "ok" | "failed"; errorMessage?: string };
|
|
266
|
+
|
|
267
|
+
switch (connectionConfig.type) {
|
|
268
|
+
case "postgres": {
|
|
269
|
+
if (!connectionConfig.postgresConnection) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"Invalid connection configuration. No postgres connection.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const postgresConfig = connectionConfig.postgresConnection;
|
|
276
|
+
if (
|
|
277
|
+
!postgresConfig.host ||
|
|
278
|
+
!postgresConfig.port ||
|
|
279
|
+
!postgresConfig.userName ||
|
|
280
|
+
!postgresConfig.password ||
|
|
281
|
+
!postgresConfig.databaseName
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
"PostgreSQL connection requires: host, port, userName, password, and databaseName",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const configReader = async () => {
|
|
289
|
+
return {
|
|
290
|
+
host: postgresConfig.host,
|
|
291
|
+
port: postgresConfig.port,
|
|
292
|
+
username: postgresConfig.userName,
|
|
293
|
+
password: postgresConfig.password,
|
|
294
|
+
databaseName: postgresConfig.databaseName,
|
|
295
|
+
connectionString: postgresConfig.connectionString,
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const postgresConnection = new PostgresConnection(
|
|
300
|
+
"testConnection",
|
|
301
|
+
() => ({}),
|
|
302
|
+
configReader,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
await postgresConnection.test();
|
|
307
|
+
testResult = { status: "ok" };
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof AxiosError) {
|
|
310
|
+
logAxiosError(error);
|
|
311
|
+
} else {
|
|
312
|
+
logger.error(error);
|
|
313
|
+
}
|
|
314
|
+
testResult = {
|
|
315
|
+
status: "failed",
|
|
316
|
+
errorMessage: (error as Error).message,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case "snowflake": {
|
|
323
|
+
if (!connectionConfig.snowflakeConnection) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
"Invalid connection configuration. No snowflake connection.",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const snowflakeConfig = connectionConfig.snowflakeConnection;
|
|
330
|
+
if (
|
|
331
|
+
!snowflakeConfig.account ||
|
|
332
|
+
!snowflakeConfig.username ||
|
|
333
|
+
!snowflakeConfig.password ||
|
|
334
|
+
!snowflakeConfig.warehouse ||
|
|
335
|
+
!snowflakeConfig.database ||
|
|
336
|
+
!snowflakeConfig.schema
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
"Snowflake connection requires: account, username, password, warehouse, database, and schema",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const snowflakeConnectionOptions = {
|
|
344
|
+
connOptions: {
|
|
345
|
+
account: snowflakeConfig.account,
|
|
346
|
+
username: snowflakeConfig.username,
|
|
347
|
+
password: snowflakeConfig.password,
|
|
348
|
+
warehouse: snowflakeConfig.warehouse,
|
|
349
|
+
database: snowflakeConfig.database,
|
|
350
|
+
schema: snowflakeConfig.schema,
|
|
351
|
+
timeout: snowflakeConfig.responseTimeoutMilliseconds,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
const snowflakeConnection = new SnowflakeConnection(
|
|
355
|
+
"testConnection",
|
|
356
|
+
snowflakeConnectionOptions,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await snowflakeConnection.test();
|
|
361
|
+
testResult = { status: "ok" };
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof AxiosError) {
|
|
364
|
+
logAxiosError(error);
|
|
365
|
+
} else {
|
|
366
|
+
logger.error(error);
|
|
367
|
+
}
|
|
368
|
+
testResult = {
|
|
369
|
+
status: "failed",
|
|
370
|
+
errorMessage: (error as Error).message,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "bigquery": {
|
|
377
|
+
if (!connectionConfig.bigqueryConnection) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"Invalid connection configuration. No bigquery connection.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const bigqueryConfig = connectionConfig.bigqueryConnection;
|
|
384
|
+
if (
|
|
385
|
+
!bigqueryConfig.serviceAccountKeyJson ||
|
|
386
|
+
!bigqueryConfig.defaultProjectId
|
|
387
|
+
) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
"BigQuery connection requires: serviceAccountKeyJson and defaultProjectId",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const serviceAccountKeyPath = path.join(
|
|
394
|
+
TEMP_DIR_PATH,
|
|
395
|
+
`test-${uuidv4()}-service-account-key.json`,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await fs.writeFile(
|
|
400
|
+
serviceAccountKeyPath,
|
|
401
|
+
connectionConfig.bigqueryConnection
|
|
402
|
+
.serviceAccountKeyJson as string,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const bigqueryConnectionOptions = {
|
|
406
|
+
projectId: connectionConfig.bigqueryConnection.defaultProjectId,
|
|
407
|
+
serviceAccountKeyPath: serviceAccountKeyPath,
|
|
408
|
+
location: connectionConfig.bigqueryConnection.location,
|
|
409
|
+
maximumBytesBilled:
|
|
410
|
+
connectionConfig.bigqueryConnection.maximumBytesBilled,
|
|
411
|
+
timeoutMs:
|
|
412
|
+
connectionConfig.bigqueryConnection.queryTimeoutMilliseconds,
|
|
413
|
+
billingProjectId:
|
|
414
|
+
connectionConfig.bigqueryConnection.billingProjectId,
|
|
415
|
+
};
|
|
416
|
+
const bigqueryConnection = new BigQueryConnection(
|
|
417
|
+
"testConnection",
|
|
418
|
+
() => ({}),
|
|
419
|
+
bigqueryConnectionOptions,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
await bigqueryConnection.test();
|
|
423
|
+
testResult = { status: "ok" };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error instanceof AxiosError) {
|
|
426
|
+
logAxiosError(error);
|
|
427
|
+
} else {
|
|
428
|
+
logger.error(error);
|
|
429
|
+
}
|
|
430
|
+
testResult = {
|
|
431
|
+
status: "failed",
|
|
432
|
+
errorMessage: (error as Error).message,
|
|
433
|
+
};
|
|
434
|
+
} finally {
|
|
435
|
+
try {
|
|
436
|
+
await fs.unlink(serviceAccountKeyPath);
|
|
437
|
+
} catch (cleanupError) {
|
|
438
|
+
logger.warn(
|
|
439
|
+
`Failed to cleanup temporary file ${serviceAccountKeyPath}:`,
|
|
440
|
+
cleanupError,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case "trino": {
|
|
448
|
+
if (!connectionConfig.trinoConnection) {
|
|
449
|
+
throw new Error("Trino connection configuration is missing.");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const trinoConfig = connectionConfig.trinoConnection;
|
|
453
|
+
if (
|
|
454
|
+
!trinoConfig.server ||
|
|
455
|
+
!trinoConfig.port ||
|
|
456
|
+
!trinoConfig.catalog ||
|
|
457
|
+
!trinoConfig.schema ||
|
|
458
|
+
!trinoConfig.user ||
|
|
459
|
+
!trinoConfig.password
|
|
460
|
+
) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
"Trino connection requires: server, port, catalog, schema, user, and password",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const trinoConnectionOptions = {
|
|
467
|
+
server: trinoConfig.server,
|
|
468
|
+
port: trinoConfig.port,
|
|
469
|
+
catalog: trinoConfig.catalog,
|
|
470
|
+
schema: trinoConfig.schema,
|
|
471
|
+
user: trinoConfig.user,
|
|
472
|
+
password: trinoConfig.password,
|
|
473
|
+
};
|
|
474
|
+
const trinoConnection = new TrinoConnection(
|
|
475
|
+
"testConnection",
|
|
476
|
+
{},
|
|
477
|
+
trinoConnectionOptions,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await trinoConnection.test();
|
|
482
|
+
testResult = { status: "ok" };
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (error instanceof AxiosError) {
|
|
485
|
+
logAxiosError(error);
|
|
486
|
+
} else {
|
|
487
|
+
logger.error(error);
|
|
488
|
+
}
|
|
489
|
+
testResult = {
|
|
490
|
+
status: "failed",
|
|
491
|
+
errorMessage: (error as Error).message,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
default:
|
|
498
|
+
throw new BadRequestError(
|
|
499
|
+
`Unsupported connection type: ${connectionConfig.type}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (testResult.status === "failed") {
|
|
504
|
+
return {
|
|
505
|
+
status: "failed",
|
|
506
|
+
errorMessage: testResult.errorMessage || "Connection test failed",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
status: "ok",
|
|
512
|
+
errorMessage: "",
|
|
513
|
+
};
|
|
514
|
+
}
|
|
@@ -6,10 +6,14 @@ import { components } from "../api";
|
|
|
6
6
|
import { isPublisherConfigFrozen } from "../config";
|
|
7
7
|
import { ProjectStore } from "./project_store";
|
|
8
8
|
import { Project } from "./project";
|
|
9
|
+
import { TEMP_DIR_PATH } from "../constants";
|
|
9
10
|
|
|
10
11
|
type Connection = components["schemas"]["Connection"];
|
|
11
12
|
|
|
12
|
-
const serverRootPath =
|
|
13
|
+
const serverRootPath = path.join(
|
|
14
|
+
TEMP_DIR_PATH,
|
|
15
|
+
"pathways-worker-publisher-project-store-test",
|
|
16
|
+
);
|
|
13
17
|
const projectName = "organizationName-projectName";
|
|
14
18
|
const testConnections: Connection[] = [
|
|
15
19
|
{
|
|
@@ -353,7 +357,10 @@ describe("ProjectStore Service", () => {
|
|
|
353
357
|
describe("Project Service Error Recovery", () => {
|
|
354
358
|
let sandbox: sinon.SinonSandbox;
|
|
355
359
|
let projectStore: ProjectStore;
|
|
356
|
-
const serverRootPath =
|
|
360
|
+
const serverRootPath = path.join(
|
|
361
|
+
TEMP_DIR_PATH,
|
|
362
|
+
"pathways-worker-publisher-error-recovery-test",
|
|
363
|
+
);
|
|
357
364
|
const projectName = "organizationName-projectName-error-recovery";
|
|
358
365
|
const testConnections: Connection[] = [
|
|
359
366
|
{
|
|
@@ -473,29 +473,6 @@ export class ProjectStore {
|
|
|
473
473
|
projectName: string,
|
|
474
474
|
packageName: string,
|
|
475
475
|
) {
|
|
476
|
-
// Handle absolute paths
|
|
477
|
-
if (location.startsWith("/")) {
|
|
478
|
-
try {
|
|
479
|
-
logger.info(
|
|
480
|
-
`Mounting local directory at "${location}" to "${targetPath}"`,
|
|
481
|
-
);
|
|
482
|
-
await this.mountLocalDirectory(
|
|
483
|
-
location,
|
|
484
|
-
targetPath,
|
|
485
|
-
projectName,
|
|
486
|
-
packageName,
|
|
487
|
-
);
|
|
488
|
-
return;
|
|
489
|
-
} catch (error) {
|
|
490
|
-
logger.error(`Failed to mount local directory "${location}"`, {
|
|
491
|
-
error,
|
|
492
|
-
});
|
|
493
|
-
throw new PackageNotFoundError(
|
|
494
|
-
`Failed to mount local directory: ${location}`,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
476
|
// Handle GCS paths
|
|
500
477
|
if (location.startsWith("gs://")) {
|
|
501
478
|
try {
|
|
@@ -553,6 +530,29 @@ export class ProjectStore {
|
|
|
553
530
|
}
|
|
554
531
|
}
|
|
555
532
|
|
|
533
|
+
// Handle absolute paths
|
|
534
|
+
if (path.isAbsolute(location)) {
|
|
535
|
+
try {
|
|
536
|
+
logger.info(
|
|
537
|
+
`Mounting local directory at "${location}" to "${targetPath}"`,
|
|
538
|
+
);
|
|
539
|
+
await this.mountLocalDirectory(
|
|
540
|
+
location,
|
|
541
|
+
targetPath,
|
|
542
|
+
projectName,
|
|
543
|
+
packageName,
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
logger.error(`Failed to mount local directory "${location}"`, {
|
|
548
|
+
error,
|
|
549
|
+
});
|
|
550
|
+
throw new PackageNotFoundError(
|
|
551
|
+
`Failed to mount local directory: ${location}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
556
|
// If we get here, the path format is not supported
|
|
557
557
|
const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
|
|
558
558
|
logger.error(errorMsg, { projectName, location });
|