@malloy-publisher/server 0.0.235 → 0.0.236
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 +47 -17
- package/dist/package_load_worker.mjs +8 -1
- package/dist/server.mjs +471 -54
- package/package.json +5 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2434 +0,0 @@
|
|
|
1
|
-
import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
|
|
2
|
-
import { Storage } from "@google-cloud/storage";
|
|
3
|
-
import { Mutex } from "async-mutex";
|
|
4
|
-
import crypto from "crypto";
|
|
5
|
-
import extract from "extract-zip";
|
|
6
|
-
import * as fs from "fs";
|
|
7
|
-
import * as os from "os";
|
|
8
|
-
import * as path from "path";
|
|
9
|
-
import simpleGit, { type SimpleGitProgressEvent } from "simple-git";
|
|
10
|
-
import { Writable } from "stream";
|
|
11
|
-
import { components } from "../api";
|
|
12
|
-
import {
|
|
13
|
-
getProcessedPublisherConfig,
|
|
14
|
-
getPublisherConfigDir,
|
|
15
|
-
isPublisherConfigFrozen,
|
|
16
|
-
ProcessedEnvironment,
|
|
17
|
-
ProcessedPublisherConfig,
|
|
18
|
-
} from "../config";
|
|
19
|
-
import {
|
|
20
|
-
API_PREFIX,
|
|
21
|
-
PUBLISHER_CONFIG_NAME,
|
|
22
|
-
PUBLISHER_DATA_DIR,
|
|
23
|
-
} from "../constants";
|
|
24
|
-
import {
|
|
25
|
-
BadRequestError,
|
|
26
|
-
EnvironmentNotFoundError,
|
|
27
|
-
FrozenConfigError,
|
|
28
|
-
PackageNotFoundError,
|
|
29
|
-
} from "../errors";
|
|
30
|
-
import { getOperationalState, markNotReady, markReady } from "../health";
|
|
31
|
-
import { formatDuration, logger } from "../logger";
|
|
32
|
-
import {
|
|
33
|
-
deleteEnvironmentEmbeddings,
|
|
34
|
-
deletePackageEmbeddings,
|
|
35
|
-
} from "../mcp/tools/embedding_index";
|
|
36
|
-
import { redactPgSecrets } from "../pg_helpers";
|
|
37
|
-
import {
|
|
38
|
-
assertSafeEnvironmentPath,
|
|
39
|
-
assertSafePackageName,
|
|
40
|
-
safeJoinUnderRoot,
|
|
41
|
-
} from "../path_safety";
|
|
42
|
-
import { Connection } from "../storage/DatabaseInterface";
|
|
43
|
-
import { StorageConfig, StorageManager } from "../storage/StorageManager";
|
|
44
|
-
import { Environment, PackageStatus } from "./environment";
|
|
45
|
-
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
46
|
-
type ApiEnvironment = components["schemas"]["Environment"];
|
|
47
|
-
type LoadError = NonNullable<
|
|
48
|
-
components["schemas"]["ServerStatus"]["loadErrors"]
|
|
49
|
-
>[number];
|
|
50
|
-
|
|
51
|
-
const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
|
|
52
|
-
const AZURE_DATA_EXTENSIONS = [
|
|
53
|
-
".parquet",
|
|
54
|
-
".csv",
|
|
55
|
-
".json",
|
|
56
|
-
".jsonl",
|
|
57
|
-
".ndjson",
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
function validateAzureUrl(url: string, fieldName: string): void {
|
|
61
|
-
if (!AZURE_SUPPORTED_SCHEMES.some((s) => url.startsWith(s))) {
|
|
62
|
-
throw new BadRequestError(
|
|
63
|
-
`Azure ${fieldName} must use one of: ${AZURE_SUPPORTED_SCHEMES.join(", ")}`,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
const pathWithoutQuery = url.split("?")[0];
|
|
67
|
-
const stars = (pathWithoutQuery.match(/\*/g) || []).length;
|
|
68
|
-
|
|
69
|
-
if (stars === 0) {
|
|
70
|
-
const lower = pathWithoutQuery.toLowerCase();
|
|
71
|
-
if (!AZURE_DATA_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
|
|
72
|
-
throw new BadRequestError(
|
|
73
|
-
`Azure ${fieldName}: a single-file URL must end with a data file extension (${AZURE_DATA_EXTENSIONS.join(", ")})`,
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
} else if (pathWithoutQuery.endsWith("**")) {
|
|
77
|
-
// recursive — valid
|
|
78
|
-
// includes all data files in the container and all subdirectories
|
|
79
|
-
} else {
|
|
80
|
-
const lastSegment = pathWithoutQuery.split("/").pop() || "";
|
|
81
|
-
if (stars !== 1 || !lastSegment.startsWith("*")) {
|
|
82
|
-
throw new BadRequestError(
|
|
83
|
-
`Azure ${fieldName}: only three URL patterns are supported:\n` +
|
|
84
|
-
` • Single file: path/file.parquet\n` +
|
|
85
|
-
` • Directory glob: path/*.ext (direct children only)\n` +
|
|
86
|
-
` • Recursive: path/** (includes all data files in the container and all subdirectories)\n` +
|
|
87
|
-
`Multi-level globs such as "sub_dir/*/*.parquet" are not supported.`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function validateEnvironmentAzureUrls(environment: ApiEnvironment): void {
|
|
94
|
-
for (const conn of environment.connections || []) {
|
|
95
|
-
if (conn.type !== "duckdb") continue;
|
|
96
|
-
for (const db of conn.duckdbConnection?.attachedDatabases || []) {
|
|
97
|
-
if (db.type !== "azure" || !db.azureConnection) continue;
|
|
98
|
-
const { authType, sasUrl, fileUrl } = db.azureConnection;
|
|
99
|
-
if (authType === "sas_token" && sasUrl) {
|
|
100
|
-
validateAzureUrl(sasUrl, `"${db.name}" sasUrl`);
|
|
101
|
-
} else if (authType === "service_principal" && fileUrl) {
|
|
102
|
-
validateAzureUrl(fileUrl, `"${db.name}" fileUrl`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Safely clear a prior mount target before (re)mounting at `targetPath`.
|
|
109
|
-
// Distinguishes symlinks from real dirs: fs.rm's recursive mode could otherwise
|
|
110
|
-
// traverse into a symlinked source and damage it, so unlink symlinks and rm real
|
|
111
|
-
// dirs. Both the in-place (symlink) and copy mount paths call this, so toggling
|
|
112
|
-
// watch mode across runs against the same publisher_data can't leave a stale
|
|
113
|
-
// entry that breaks the next mount (mkdir is a no-op on a symlink and fs.cp then
|
|
114
|
-
// throws ERR_FS_CP_DIR_TO_NON_DIR).
|
|
115
|
-
async function clearMountTarget(targetPath: string): Promise<void> {
|
|
116
|
-
try {
|
|
117
|
-
const stats = await fs.promises.lstat(targetPath);
|
|
118
|
-
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
119
|
-
await fs.promises.rm(targetPath, { recursive: true, force: true });
|
|
120
|
-
} else {
|
|
121
|
-
await fs.promises.unlink(targetPath);
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
// Nothing there, fine.
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Absolute on-disk path for a local package `location`.
|
|
130
|
-
*
|
|
131
|
-
* `~/` (POSIX form only) expands to the home directory. Anything still
|
|
132
|
-
* relative resolves against `anchorDir`, the directory holding the active
|
|
133
|
-
* config, so a config and the packages it points at can be moved or
|
|
134
|
-
* committed together. Absolute locations are returned untouched.
|
|
135
|
-
*/
|
|
136
|
-
export function resolvePackageLocation(
|
|
137
|
-
location: string,
|
|
138
|
-
anchorDir: string,
|
|
139
|
-
homeDir?: string,
|
|
140
|
-
): string {
|
|
141
|
-
let expanded = location;
|
|
142
|
-
if (location.startsWith("~/")) {
|
|
143
|
-
// Resolved lazily, inside the tilde branch only: os.homedir() can throw
|
|
144
|
-
// where HOME is unset and there is no passwd entry for the uid (e.g. a
|
|
145
|
-
// distroless container with runAsUser), and a `./sales` or absolute
|
|
146
|
-
// location must never fail on that.
|
|
147
|
-
const home = homeDir ?? os.homedir();
|
|
148
|
-
if (!home) {
|
|
149
|
-
throw new Error(
|
|
150
|
-
`Cannot expand "~" in location "${location}": home directory is not set`,
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
expanded = path.join(home, location.slice(2));
|
|
154
|
-
}
|
|
155
|
-
return path.isAbsolute(expanded) ? expanded : path.join(anchorDir, expanded);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Options for every package git clone. Packages are served from the default
|
|
160
|
-
* branch's working tree only (no ref is ever checked out afterwards, and no
|
|
161
|
-
* later git operation touches the clone: a reload deletes and re-clones), so
|
|
162
|
-
* history is pure download cost. Exported for tests.
|
|
163
|
-
*/
|
|
164
|
-
export const GIT_CLONE_OPTIONS: Record<string, string | number | null> = {
|
|
165
|
-
"--depth": 1,
|
|
166
|
-
"--single-branch": null,
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* With a progress handler configured, simple-git forces `--progress`, and a
|
|
171
|
-
* failed clone's error message then carries every progress line git wrote to
|
|
172
|
-
* stderr. Those messages surface in API error responses on the add/update
|
|
173
|
-
* package path and in the failure logs (the boot path's `/status` loadErrors
|
|
174
|
-
* carries a location-level summary instead), so drop the noise and keep the
|
|
175
|
-
* lines that say what went wrong. Returns the input when everything matched,
|
|
176
|
-
* rather than an empty error.
|
|
177
|
-
*/
|
|
178
|
-
export function stripGitProgressNoise(message: string): string {
|
|
179
|
-
const kept = message
|
|
180
|
-
.split(/\r\n|\r|\n/)
|
|
181
|
-
.filter(
|
|
182
|
-
(line) =>
|
|
183
|
-
!/\d+% \(\d+\/\d+\)/.test(line) &&
|
|
184
|
-
// Also matches the "Error: Cloning into ..." shape V8 seeds
|
|
185
|
-
// into err.stack from the message's first line.
|
|
186
|
-
!/^(\w+: )?Cloning into /.test(line.trim()),
|
|
187
|
-
)
|
|
188
|
-
.join("\n")
|
|
189
|
-
.trim();
|
|
190
|
-
return kept.length > 0 ? kept : message.trim();
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** e.g. `[examples] cloning malloydata/publisher (storefront, +2 more)`. */
|
|
194
|
-
export function cloneProgressLabel(
|
|
195
|
-
repo: string,
|
|
196
|
-
context?: { environmentName?: string; packageNames?: string[] },
|
|
197
|
-
): string {
|
|
198
|
-
const env = context?.environmentName ? `[${context.environmentName}] ` : "";
|
|
199
|
-
const names = context?.packageNames ?? [];
|
|
200
|
-
const shown = names.length > 4 ? names.slice(0, 3) : names;
|
|
201
|
-
const more = names.length - shown.length;
|
|
202
|
-
const pkgs =
|
|
203
|
-
shown.length > 0
|
|
204
|
-
? ` (${shown.join(", ")}${more > 0 ? `, +${more} more` : ""})`
|
|
205
|
-
: "";
|
|
206
|
-
return `${env}cloning ${repo}${pkgs}`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Streams git clone progress to stderr: off winston (whose format changes
|
|
211
|
-
* under OTEL and whose level can hide info lines) and off stdout (which an
|
|
212
|
-
* MCP stdio transport would own), and it is where git itself reports
|
|
213
|
-
* progress. A TTY gets one line rewritten in place; anything else (CI logs)
|
|
214
|
-
* gets a line per stage and per 25-point step, so a slow clone shows life
|
|
215
|
-
* without flooding. Exported for tests.
|
|
216
|
-
*/
|
|
217
|
-
export class CloneProgressReporter {
|
|
218
|
-
private lastStage: string | undefined;
|
|
219
|
-
private lastMilestone = -1;
|
|
220
|
-
private wroteInPlace = false;
|
|
221
|
-
private finished = false;
|
|
222
|
-
/**
|
|
223
|
-
* One reporter at a time owns a TTY's in-place line: environments load
|
|
224
|
-
* concurrently at boot, and two reporters `\r`-rewriting the same line
|
|
225
|
-
* would overwrite each other. Later reporters fall back to milestone
|
|
226
|
-
* lines until the owner finishes. Keyed by stream so tests with fake
|
|
227
|
-
* streams cannot collide.
|
|
228
|
-
*/
|
|
229
|
-
private static ttyOwners = new WeakMap<object, CloneProgressReporter>();
|
|
230
|
-
|
|
231
|
-
constructor(
|
|
232
|
-
private readonly label: string,
|
|
233
|
-
private readonly out: NodeJS.WriteStream = process.stderr,
|
|
234
|
-
) {}
|
|
235
|
-
|
|
236
|
-
onProgress(event: SimpleGitProgressEvent): void {
|
|
237
|
-
if (this.finished) {
|
|
238
|
-
// A late event after done() must not re-claim the TTY line the
|
|
239
|
-
// finishing newline just released.
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const { stage, progress, processed, total } = event;
|
|
243
|
-
const counts = total > 0 ? ` (${processed}/${total})` : "";
|
|
244
|
-
const text = `${this.label}: ${stage} ${progress}%${counts}`;
|
|
245
|
-
if (this.out.isTTY === true && this.claimTtyLine()) {
|
|
246
|
-
// Clamped to the terminal width: an auto-wrapped line breaks the
|
|
247
|
-
// \r rewrite (git clamps its own progress output for the same
|
|
248
|
-
// reason). The moving part sits at the end, so shorten the label
|
|
249
|
-
// and keep the stage/percentage visible; only when even the
|
|
250
|
-
// suffix cannot fit does a plain head slice apply. \x1b[K clears
|
|
251
|
-
// the tail of a longer previous line.
|
|
252
|
-
const budget = Math.max(1, (this.out.columns || 80) - 1);
|
|
253
|
-
const suffix = `: ${stage} ${progress}%${counts}`;
|
|
254
|
-
let line: string;
|
|
255
|
-
if (text.length <= budget) {
|
|
256
|
-
line = text;
|
|
257
|
-
} else if (suffix.length + 4 <= budget) {
|
|
258
|
-
const labelBudget = budget - suffix.length;
|
|
259
|
-
line = `${this.label.slice(0, labelBudget - 3)}...${suffix}`;
|
|
260
|
-
} else {
|
|
261
|
-
line = text.slice(0, budget);
|
|
262
|
-
}
|
|
263
|
-
this.out.write(`\r${line}\x1b[K`);
|
|
264
|
-
this.wroteInPlace = true;
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const milestone = Math.floor(progress / 25);
|
|
268
|
-
if (stage !== this.lastStage || milestone < this.lastMilestone) {
|
|
269
|
-
// A new stage, or a percentage restart inside one reported stage:
|
|
270
|
-
// git's server-side counting and compressing phases both arrive as
|
|
271
|
-
// stage "remote:", so a drop in progress marks the next sub-phase.
|
|
272
|
-
this.lastStage = stage;
|
|
273
|
-
this.lastMilestone = milestone;
|
|
274
|
-
} else if (milestone > this.lastMilestone) {
|
|
275
|
-
this.lastMilestone = milestone;
|
|
276
|
-
} else {
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
if (this.out.isTTY === true) {
|
|
280
|
-
// A non-owner on a TTY: the cursor may sit mid-line on the owner's
|
|
281
|
-
// in-place output, so take over the row cleanly and scroll it.
|
|
282
|
-
this.out.write(`\r${text}\x1b[K\n`);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
this.out.write(`${text}\n`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
done(): void {
|
|
289
|
-
this.finished = true;
|
|
290
|
-
if (this.wroteInPlace) {
|
|
291
|
-
this.out.write("\n");
|
|
292
|
-
}
|
|
293
|
-
if (CloneProgressReporter.ttyOwners.get(this.out) === this) {
|
|
294
|
-
CloneProgressReporter.ttyOwners.delete(this.out);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
private claimTtyLine(): boolean {
|
|
299
|
-
const owner = CloneProgressReporter.ttyOwners.get(this.out);
|
|
300
|
-
if (owner === undefined) {
|
|
301
|
-
CloneProgressReporter.ttyOwners.set(this.out, this);
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
return owner === this;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* The last `/segment` of a run of non-empty path segments at the start of
|
|
310
|
-
* `afterRepo`, or undefined. Reproduces the capture of the old
|
|
311
|
-
* `(\/[^/]+)*` group, which retained only its final iteration and stopped at
|
|
312
|
-
* the first empty segment.
|
|
313
|
-
*/
|
|
314
|
-
function lastGitHubPathSegment(afterRepo: string): string | undefined {
|
|
315
|
-
if (!afterRepo.startsWith("/")) return undefined;
|
|
316
|
-
const run: string[] = [];
|
|
317
|
-
for (const seg of afterRepo.slice(1).split("/")) {
|
|
318
|
-
if (seg === "") break;
|
|
319
|
-
run.push(seg);
|
|
320
|
-
}
|
|
321
|
-
return run.length > 0 ? `/${run[run.length - 1]}` : undefined;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Parse a GitHub package location into owner, repo, and (optionally) the
|
|
326
|
-
* subdirectory path segment.
|
|
327
|
-
*
|
|
328
|
-
* Uses a repetition-free regex for owner/repo plus plain string ops for the
|
|
329
|
-
* path, NOT a single `(\/[^/]+)*` regex: those shapes were flagged as
|
|
330
|
-
* polynomial-backtracking (ReDoS) on adversarial input, and the location is
|
|
331
|
-
* user/config supplied. Behavior is preserved exactly, including
|
|
332
|
-
* `packagePath` being only the LAST path segment after the repo (a quirk the
|
|
333
|
-
* callers rely on); environment_store.spec.ts fuzzes the old regexes as an
|
|
334
|
-
* oracle to prove equivalence. Exported for that test.
|
|
335
|
-
*/
|
|
336
|
-
export function parseGitHubUrl(
|
|
337
|
-
githubUrl: string,
|
|
338
|
-
): { owner: string; repoName: string; packagePath?: string } | null {
|
|
339
|
-
// HTTPS: https://github.com/owner/repo/tree/branch/subdir. The host may
|
|
340
|
-
// appear anywhere (the old regex was unanchored); the owner/repo pattern
|
|
341
|
-
// has no repetition group, so it cannot backtrack polynomially.
|
|
342
|
-
const httpsMatch = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl);
|
|
343
|
-
if (httpsMatch) {
|
|
344
|
-
const afterRepo = githubUrl.slice(
|
|
345
|
-
httpsMatch.index + httpsMatch[0].length,
|
|
346
|
-
);
|
|
347
|
-
return {
|
|
348
|
-
owner: httpsMatch[1],
|
|
349
|
-
repoName: httpsMatch[2],
|
|
350
|
-
packagePath: lastGitHubPathSegment(afterRepo),
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// SSH: git@github.com:owner/repo(.git)?(/subdir)*. The old regex was
|
|
355
|
-
// $-anchored, so the whole tail after the repo had to be a run of
|
|
356
|
-
// non-empty `/segment`s; a trailing slash or empty segment matched nothing
|
|
357
|
-
// and failed the parse.
|
|
358
|
-
// `git@github.com:` may appear anywhere (the old regex was not anchored at
|
|
359
|
-
// the start), though in practice isGitHubURL only routes locations that
|
|
360
|
-
// begin with it here.
|
|
361
|
-
const SSH_PREFIX = "git@github.com:";
|
|
362
|
-
const sshAt = githubUrl.indexOf(SSH_PREFIX);
|
|
363
|
-
if (sshAt !== -1) {
|
|
364
|
-
const rest = githubUrl.slice(sshAt + SSH_PREFIX.length);
|
|
365
|
-
const firstSlash = rest.indexOf("/");
|
|
366
|
-
if (firstSlash > 0) {
|
|
367
|
-
const owner = rest.slice(0, firstSlash);
|
|
368
|
-
const afterOwner = rest.slice(firstSlash + 1);
|
|
369
|
-
const secondSlash = afterOwner.indexOf("/");
|
|
370
|
-
const repoSeg =
|
|
371
|
-
secondSlash === -1 ? afterOwner : afterOwner.slice(0, secondSlash);
|
|
372
|
-
const afterRepo =
|
|
373
|
-
secondSlash === -1 ? "" : afterOwner.slice(secondSlash);
|
|
374
|
-
// repo was `[^/\s]+` (no whitespace); anything else falls through to
|
|
375
|
-
// "not a GitHub URL", as the old regex did.
|
|
376
|
-
if (repoSeg.length > 0 && !/\s/.test(repoSeg)) {
|
|
377
|
-
const repoName =
|
|
378
|
-
repoSeg.length > 4 && repoSeg.endsWith(".git")
|
|
379
|
-
? repoSeg.slice(0, -4)
|
|
380
|
-
: repoSeg;
|
|
381
|
-
if (afterRepo === "") {
|
|
382
|
-
return { owner, repoName };
|
|
383
|
-
}
|
|
384
|
-
const segments = afterRepo.slice(1).split("/");
|
|
385
|
-
// $-anchored: every trailing segment had to be non-empty.
|
|
386
|
-
if (!segments.includes("")) {
|
|
387
|
-
return {
|
|
388
|
-
owner,
|
|
389
|
-
repoName,
|
|
390
|
-
packagePath: `/${segments[segments.length - 1]}`,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/** `0.0.0.0`/`::` bind every interface; a script needs a host it can dial. */
|
|
401
|
-
function displayHost(host: string): string {
|
|
402
|
-
if (host === "0.0.0.0" || host === "::") {
|
|
403
|
-
return "localhost";
|
|
404
|
-
}
|
|
405
|
-
// An IPv6 literal needs brackets to be dialable inside a URL.
|
|
406
|
-
return host.includes(":") ? `[${host}]` : host;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* The one-line machine-readable boot signal. Scripts grep for the
|
|
411
|
-
* `PUBLISHER_READY` prefix instead of polling `/api/v0/status`, and the
|
|
412
|
-
* counts carry what `operationalState: "serving"` alone does not say: how
|
|
413
|
-
* much of the configured world actually loaded. Host and ports mirror
|
|
414
|
-
* server.ts's use-site defaults; parseArgs writes the flag values into
|
|
415
|
-
* process.env before the store is constructed. Exported for tests.
|
|
416
|
-
*/
|
|
417
|
-
export function formatReadinessLine(counts: {
|
|
418
|
-
environments: number;
|
|
419
|
-
packages: number;
|
|
420
|
-
loadErrors: number;
|
|
421
|
-
}): string {
|
|
422
|
-
const host = displayHost(process.env.PUBLISHER_HOST || "0.0.0.0");
|
|
423
|
-
const port = Number(process.env.PUBLISHER_PORT || 4000);
|
|
424
|
-
const mcpPort = Number(process.env.MCP_PORT || 4040);
|
|
425
|
-
return (
|
|
426
|
-
`PUBLISHER_READY url=http://${host}:${port} ` +
|
|
427
|
-
`mcp=http://${host}:${mcpPort} ` +
|
|
428
|
-
`environments=${counts.environments} packages=${counts.packages} ` +
|
|
429
|
-
`load_errors=${counts.loadErrors}`
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
export class EnvironmentStore {
|
|
434
|
-
public serverRootPath: string;
|
|
435
|
-
private environments: Map<string, Environment> = new Map();
|
|
436
|
-
/**
|
|
437
|
-
* Environments that were configured but did not load, keyed by name.
|
|
438
|
-
*
|
|
439
|
-
* A load failure is not fatal: the environment is skipped and the server
|
|
440
|
-
* still reports `serving`. Nothing else remembers it, because the throw
|
|
441
|
-
* happens before the environment reaches `this.environments` or the
|
|
442
|
-
* database, so this is the only record that a configured environment is
|
|
443
|
-
* missing rather than never asked for. Surfaced on `getStatus`.
|
|
444
|
-
*/
|
|
445
|
-
private failedEnvironments = new Map<string, string>();
|
|
446
|
-
private environmentMutexes = new Map<string, Mutex>();
|
|
447
|
-
public publisherConfigIsFrozen: boolean;
|
|
448
|
-
public finishedInitialization: Promise<void>;
|
|
449
|
-
private isInitialized: boolean = false;
|
|
450
|
-
public storageManager: StorageManager;
|
|
451
|
-
private s3Client = new S3({
|
|
452
|
-
followRegionRedirects: true,
|
|
453
|
-
});
|
|
454
|
-
private gcsClient: Storage;
|
|
455
|
-
// Shared by every Environment so the back-pressure decision is
|
|
456
|
-
// process-wide. Set once at server start via setMemoryGovernor;
|
|
457
|
-
// new Environments pick it up at construction.
|
|
458
|
-
private memoryGovernor: PackageMemoryGovernor | null = null;
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Set of environment names that should be loaded "in place" — i.e. the
|
|
462
|
-
* package directories under `publisher_data/<envName>/` are symlinks to
|
|
463
|
-
* the original local source directories instead of recursive copies.
|
|
464
|
-
*
|
|
465
|
-
* In-place mode powers the `npm run dev`-style live-reload story: edits
|
|
466
|
-
* to your source repo are visible to Publisher and trigger watch events
|
|
467
|
-
* that fan out via SSE to embedded HTML pages. Production mode (the
|
|
468
|
-
* default) keeps the safe copy semantics so the source repo and the
|
|
469
|
-
* served package are decoupled.
|
|
470
|
-
*
|
|
471
|
-
* Populated from the `PUBLISHER_WATCH` env var (comma-separated env
|
|
472
|
-
* names) at construction; can also be augmented via `markInPlace()` if
|
|
473
|
-
* a future feature wants to flip an environment to dev mode at runtime.
|
|
474
|
-
*
|
|
475
|
-
* Only LOCAL-DIR package locations are eligible for symlinking; remote
|
|
476
|
-
* sources (GitHub, GCS, S3) always copy regardless.
|
|
477
|
-
*/
|
|
478
|
-
private inPlaceEnvs = new Set<string>();
|
|
479
|
-
|
|
480
|
-
constructor(serverRootPath: string) {
|
|
481
|
-
this.serverRootPath = serverRootPath;
|
|
482
|
-
this.gcsClient = new Storage();
|
|
483
|
-
|
|
484
|
-
const watchEnvList = (process.env.PUBLISHER_WATCH || "")
|
|
485
|
-
.split(",")
|
|
486
|
-
.map((s) => s.trim())
|
|
487
|
-
.filter((s) => s.length > 0);
|
|
488
|
-
for (const envName of watchEnvList) this.inPlaceEnvs.add(envName);
|
|
489
|
-
|
|
490
|
-
const storageConfig: StorageConfig = {
|
|
491
|
-
type: "duckdb",
|
|
492
|
-
duckdb: {
|
|
493
|
-
path: path.join(serverRootPath, "publisher.db"),
|
|
494
|
-
},
|
|
495
|
-
};
|
|
496
|
-
this.storageManager = new StorageManager(storageConfig);
|
|
497
|
-
|
|
498
|
-
this.finishedInitialization = this.initialize();
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** True if this env should mount package dirs in place (symlinks). */
|
|
502
|
-
public isInPlace(environmentName: string): boolean {
|
|
503
|
-
return this.inPlaceEnvs.has(environmentName);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/** Add an env to in-place mode at runtime. Caller is responsible for
|
|
507
|
-
* triggering a re-mount if the env was already loaded with copies. */
|
|
508
|
-
public markInPlace(environmentName: string): void {
|
|
509
|
-
this.inPlaceEnvs.add(environmentName);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
|
|
514
|
-
* Propagated to every Environment so the back-pressure decision is
|
|
515
|
-
* process-wide, and remembered so any Environment created *after*
|
|
516
|
-
* this call also picks it up at construction.
|
|
517
|
-
*/
|
|
518
|
-
public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
|
|
519
|
-
this.memoryGovernor = governor;
|
|
520
|
-
for (const env of this.environments.values()) {
|
|
521
|
-
env.setMemoryGovernor(governor);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Snapshot of the environments currently held in memory. Used by the
|
|
527
|
-
* standalone materialization scheduler to sweep loaded packages without
|
|
528
|
-
* touching the database or triggering loads.
|
|
529
|
-
*/
|
|
530
|
-
public getLoadedEnvironments(): Environment[] {
|
|
531
|
-
return [...this.environments.values()];
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
|
|
535
|
-
try {
|
|
536
|
-
await this.addEnvironment(
|
|
537
|
-
{
|
|
538
|
-
name: environment.name,
|
|
539
|
-
resource: `${API_PREFIX}/environments/${environment.name}`,
|
|
540
|
-
connections: environment.connections,
|
|
541
|
-
packages: environment.packages,
|
|
542
|
-
},
|
|
543
|
-
true,
|
|
544
|
-
);
|
|
545
|
-
} catch (error) {
|
|
546
|
-
this.recordEnvironmentInitializationFailure(environment.name, error);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Log a skipped environment AND remember why, for getStatus to report.
|
|
552
|
-
*
|
|
553
|
-
* Both boot paths funnel through here: the config path
|
|
554
|
-
* ({@link addConfiguredEnvironment}) and the database-restore path taken by
|
|
555
|
-
* every restart against an existing publisher.db. Keeping the record here
|
|
556
|
-
* rather than in the callers is deliberate: recording it in only one of them
|
|
557
|
-
* would leave the other silently dropping failures, which is the bug this is
|
|
558
|
-
* meant to fix.
|
|
559
|
-
*/
|
|
560
|
-
private recordEnvironmentInitializationFailure(
|
|
561
|
-
environmentName: string | undefined,
|
|
562
|
-
error: unknown,
|
|
563
|
-
) {
|
|
564
|
-
const label = environmentName ? ` "${environmentName}"` : "";
|
|
565
|
-
logger.error(
|
|
566
|
-
`Error initializing environment${label}; skipping environment`,
|
|
567
|
-
this.extractErrorDataFromError(error),
|
|
568
|
-
);
|
|
569
|
-
// Only record an environment that is genuinely not there. An environment
|
|
570
|
-
// reaches `this.environments` before the rest of its setup (the database
|
|
571
|
-
// sync, its package listing) is done, and a throw from that tail lands
|
|
572
|
-
// here while the environment is live and serving. Reporting it would
|
|
573
|
-
// contradict `environments`, which still lists it, and a loadErrors entry
|
|
574
|
-
// with no `package` means the whole environment was skipped. It also could
|
|
575
|
-
// never be cleared: the clear below only runs when an environment is
|
|
576
|
-
// created, so a live one would carry the entry until restart.
|
|
577
|
-
if (environmentName && !this.environments.has(environmentName)) {
|
|
578
|
-
// Redacted because this is bound for an HTTP response body, not just a
|
|
579
|
-
// log line; see the same call in environment.ts.
|
|
580
|
-
this.failedEnvironments.set(
|
|
581
|
-
environmentName,
|
|
582
|
-
redactPgSecrets(
|
|
583
|
-
error instanceof Error ? error.message : String(error),
|
|
584
|
-
),
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
private async initialize() {
|
|
590
|
-
const reInit = process.env.INITIALIZE_STORAGE === "true";
|
|
591
|
-
const initialTime = performance.now();
|
|
592
|
-
|
|
593
|
-
try {
|
|
594
|
-
await this.storageManager.initialize(reInit);
|
|
595
|
-
|
|
596
|
-
this.publisherConfigIsFrozen = isPublisherConfigFrozen(
|
|
597
|
-
this.serverRootPath,
|
|
598
|
-
);
|
|
599
|
-
|
|
600
|
-
const environmentManifest =
|
|
601
|
-
await EnvironmentStore.reloadEnvironmentManifest(
|
|
602
|
-
this.serverRootPath,
|
|
603
|
-
);
|
|
604
|
-
|
|
605
|
-
await this.cleanupAndCreatePublisherPath();
|
|
606
|
-
|
|
607
|
-
const repository = this.storageManager.getRepository();
|
|
608
|
-
|
|
609
|
-
if (reInit) {
|
|
610
|
-
// Load environments from config file
|
|
611
|
-
await Promise.all(
|
|
612
|
-
environmentManifest.environments.map(async (environment) => {
|
|
613
|
-
await this.addConfiguredEnvironment(environment);
|
|
614
|
-
}),
|
|
615
|
-
);
|
|
616
|
-
} else {
|
|
617
|
-
// Load existing environments from database
|
|
618
|
-
const existingEnvironments = await repository.listEnvironments();
|
|
619
|
-
|
|
620
|
-
if (existingEnvironments.length > 0) {
|
|
621
|
-
// Load environments from database
|
|
622
|
-
await Promise.all(
|
|
623
|
-
existingEnvironments.map(async (dbEnvironment) => {
|
|
624
|
-
try {
|
|
625
|
-
// Check if environment files exist on disk
|
|
626
|
-
const environmentExists = await fs.promises
|
|
627
|
-
.access(dbEnvironment.path)
|
|
628
|
-
.then(() => true)
|
|
629
|
-
.catch(() => false);
|
|
630
|
-
|
|
631
|
-
if (!environmentExists) {
|
|
632
|
-
// Try to find in config and reload
|
|
633
|
-
const environmentConfig =
|
|
634
|
-
environmentManifest.environments.find(
|
|
635
|
-
(p) => p.name === dbEnvironment.name,
|
|
636
|
-
);
|
|
637
|
-
|
|
638
|
-
if (environmentConfig) {
|
|
639
|
-
const environmentInstance =
|
|
640
|
-
await this.addEnvironment(
|
|
641
|
-
{
|
|
642
|
-
name: environmentConfig.name,
|
|
643
|
-
resource: `${API_PREFIX}/environments/${environmentConfig.name}`,
|
|
644
|
-
connections:
|
|
645
|
-
environmentConfig.connections,
|
|
646
|
-
packages: environmentConfig.packages,
|
|
647
|
-
},
|
|
648
|
-
true,
|
|
649
|
-
);
|
|
650
|
-
|
|
651
|
-
// Update database with new path
|
|
652
|
-
await repository.updateEnvironment(
|
|
653
|
-
dbEnvironment.id,
|
|
654
|
-
{
|
|
655
|
-
path: environmentInstance.metadata.location,
|
|
656
|
-
},
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
return environmentInstance.listPackages();
|
|
660
|
-
} else {
|
|
661
|
-
logger.error(
|
|
662
|
-
`Environment "${dbEnvironment.name}" not found in config and files missing`,
|
|
663
|
-
);
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Get connections from database
|
|
669
|
-
const connections = await repository.listConnections(
|
|
670
|
-
dbEnvironment.id,
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
const environmentInstance = await Environment.create(
|
|
674
|
-
dbEnvironment.name,
|
|
675
|
-
dbEnvironment.path,
|
|
676
|
-
connections.map((conn) => ({
|
|
677
|
-
name: conn.name,
|
|
678
|
-
type: conn.type,
|
|
679
|
-
resource: `${API_PREFIX}/connections/${conn.name}`,
|
|
680
|
-
...conn.config,
|
|
681
|
-
})),
|
|
682
|
-
);
|
|
683
|
-
environmentInstance.setMemoryGovernor(
|
|
684
|
-
this.memoryGovernor,
|
|
685
|
-
);
|
|
686
|
-
// Re-establish serve routing when a package loads, from
|
|
687
|
-
// its latest successful materialization — so serving
|
|
688
|
-
// survives a restart, not only a fresh build. The full
|
|
689
|
-
// entry map is returned; the environment splits it by
|
|
690
|
-
// tier (colocated + storage=) in
|
|
691
|
-
// rebindServeBindingsFromLocalStore.
|
|
692
|
-
const envId = dbEnvironment.id;
|
|
693
|
-
environmentInstance.setStorageBindingResolver(
|
|
694
|
-
async (packageName) => {
|
|
695
|
-
const runs =
|
|
696
|
-
await repository.listMaterializations(
|
|
697
|
-
envId,
|
|
698
|
-
packageName,
|
|
699
|
-
);
|
|
700
|
-
const latest = runs.find(
|
|
701
|
-
(m) =>
|
|
702
|
-
m.status === "MANIFEST_FILE_READY" &&
|
|
703
|
-
m.manifest?.entries,
|
|
704
|
-
);
|
|
705
|
-
return latest?.manifest?.entries ?? {};
|
|
706
|
-
},
|
|
707
|
-
);
|
|
708
|
-
|
|
709
|
-
// Get packages from database
|
|
710
|
-
const packages = await repository.listPackages(
|
|
711
|
-
dbEnvironment.id,
|
|
712
|
-
);
|
|
713
|
-
packages.forEach((pkg) => {
|
|
714
|
-
environmentInstance.setPackageStatus(
|
|
715
|
-
pkg.name,
|
|
716
|
-
PackageStatus.SERVING,
|
|
717
|
-
);
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
this.environments.set(
|
|
721
|
-
dbEnvironment.name,
|
|
722
|
-
environmentInstance,
|
|
723
|
-
);
|
|
724
|
-
|
|
725
|
-
return environmentInstance.listPackages();
|
|
726
|
-
} catch (error) {
|
|
727
|
-
this.recordEnvironmentInitializationFailure(
|
|
728
|
-
dbEnvironment.name,
|
|
729
|
-
error,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
|
-
}),
|
|
733
|
-
);
|
|
734
|
-
} else {
|
|
735
|
-
// Fallback to config file if database is empty
|
|
736
|
-
await Promise.all(
|
|
737
|
-
environmentManifest.environments.map(async (environment) => {
|
|
738
|
-
await this.addConfiguredEnvironment(environment);
|
|
739
|
-
}),
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
this.isInitialized = true;
|
|
745
|
-
markReady();
|
|
746
|
-
const initializationDuration = performance.now() - initialTime;
|
|
747
|
-
logger.info(
|
|
748
|
-
`Environment store successfully initialized in ${formatDuration(initializationDuration)}`,
|
|
749
|
-
);
|
|
750
|
-
this.emitReadinessLine();
|
|
751
|
-
} catch (error) {
|
|
752
|
-
markNotReady();
|
|
753
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
754
|
-
logger.error("Error initializing environment store", errorData);
|
|
755
|
-
try {
|
|
756
|
-
// The failure counterpart of PUBLISHER_READY, so a script
|
|
757
|
-
// waiting on that token fails fast instead of hanging on a
|
|
758
|
-
// broken config. Redacted because initialization errors can
|
|
759
|
-
// carry connection strings.
|
|
760
|
-
process.stderr.write(
|
|
761
|
-
`PUBLISHER_INIT_FAILED error=${JSON.stringify(
|
|
762
|
-
redactPgSecrets(
|
|
763
|
-
error instanceof Error ? error.message : String(error),
|
|
764
|
-
),
|
|
765
|
-
)}\n`,
|
|
766
|
-
);
|
|
767
|
-
} catch {
|
|
768
|
-
// A failed stderr write must not mask the logged error above.
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Printed exactly once, from initialize()'s success tail: markReady() has
|
|
775
|
-
* just flipped /api/v0/status to "serving", and both HTTP listeners bound
|
|
776
|
-
* long before the downloads finished, so this is the same moment every
|
|
777
|
-
* existing readiness consumer (CI status polls, MCP /health/readiness)
|
|
778
|
-
* fires on. The catch above swallows a failed boot after markNotReady(),
|
|
779
|
-
* so the line never prints for a server that is not actually serving.
|
|
780
|
-
*/
|
|
781
|
-
private emitReadinessLine(): void {
|
|
782
|
-
let packages = 0;
|
|
783
|
-
let loadErrors = this.failedEnvironments.size;
|
|
784
|
-
for (const environment of this.environments.values()) {
|
|
785
|
-
packages += environment.getServingPackageCount();
|
|
786
|
-
loadErrors += environment.getFailedPackages().size;
|
|
787
|
-
}
|
|
788
|
-
try {
|
|
789
|
-
process.stderr.write(
|
|
790
|
-
formatReadinessLine({
|
|
791
|
-
environments: this.environments.size,
|
|
792
|
-
packages,
|
|
793
|
-
loadErrors,
|
|
794
|
-
}) + "\n",
|
|
795
|
-
);
|
|
796
|
-
} catch {
|
|
797
|
-
// A failed stderr write must not fail a boot that is already
|
|
798
|
-
// serving.
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
public async addEnvironmentToDatabase(
|
|
803
|
-
environment: Environment,
|
|
804
|
-
): Promise<void> {
|
|
805
|
-
if (!environment) {
|
|
806
|
-
logger.error("Cannot sync: environment is null or undefined");
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
const environmentName = environment.metadata?.name;
|
|
811
|
-
if (!environmentName) {
|
|
812
|
-
throw new Error("Environment name is required but not found");
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const repository = this.storageManager.getRepository();
|
|
816
|
-
|
|
817
|
-
// Sync environment metadata
|
|
818
|
-
const dbEnvironment = await this.addEnvironmentMetadata(
|
|
819
|
-
environment,
|
|
820
|
-
repository,
|
|
821
|
-
);
|
|
822
|
-
|
|
823
|
-
// Sync connections
|
|
824
|
-
await this.addConnections(environment, dbEnvironment.id, repository);
|
|
825
|
-
|
|
826
|
-
// Sync packages
|
|
827
|
-
await this.addPackages(environment, dbEnvironment.id, repository);
|
|
828
|
-
|
|
829
|
-
logger.info(`Synced environment "${environmentName}" to database`);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
public async deleteEnvironmentFromDatabase(
|
|
833
|
-
environmentName: string,
|
|
834
|
-
): Promise<void> {
|
|
835
|
-
// Before the metadata lookup, for the same reason as the package
|
|
836
|
-
// variant: the cleanup needs only the name, never the row.
|
|
837
|
-
this.cleanupEnvironmentEmbeddings(environmentName);
|
|
838
|
-
|
|
839
|
-
const repository = this.storageManager.getRepository();
|
|
840
|
-
|
|
841
|
-
// Get the environment from database
|
|
842
|
-
const dbEnvironment =
|
|
843
|
-
await repository.getEnvironmentByName(environmentName);
|
|
844
|
-
|
|
845
|
-
if (!dbEnvironment) {
|
|
846
|
-
logger.error(`Environment "${environmentName}" not found in database`);
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Delete the environment (this will cascade delete connections and packages)
|
|
851
|
-
await repository.deleteEnvironment(dbEnvironment.id);
|
|
852
|
-
logger.info(`Deleted environment "${environmentName}" from database`);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
private async addEnvironmentMetadata(
|
|
856
|
-
environment: Environment,
|
|
857
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
858
|
-
): Promise<{ id: string; name: string }> {
|
|
859
|
-
const environmentName = environment.metadata?.name;
|
|
860
|
-
if (!environmentName) {
|
|
861
|
-
throw new Error("Environment name is required but not found");
|
|
862
|
-
}
|
|
863
|
-
const environmentPath = environment.metadata?.location || "";
|
|
864
|
-
const environmentDescription = environment.metadata?.readme;
|
|
865
|
-
|
|
866
|
-
const environmentData = {
|
|
867
|
-
name: environmentName,
|
|
868
|
-
path: environmentPath,
|
|
869
|
-
description: environmentDescription,
|
|
870
|
-
metadata: environment.metadata || {},
|
|
871
|
-
};
|
|
872
|
-
const existingEnvironment =
|
|
873
|
-
await repository.getEnvironmentByName(environmentName);
|
|
874
|
-
|
|
875
|
-
let dbEnvironment: { id: string; name: string };
|
|
876
|
-
if (existingEnvironment) {
|
|
877
|
-
const updateData = {
|
|
878
|
-
description: environmentDescription,
|
|
879
|
-
metadata: environment.metadata || {},
|
|
880
|
-
};
|
|
881
|
-
|
|
882
|
-
await repository.updateEnvironment(existingEnvironment.id, updateData);
|
|
883
|
-
dbEnvironment = { id: existingEnvironment.id, name: environmentName };
|
|
884
|
-
} else {
|
|
885
|
-
dbEnvironment = await repository.createEnvironment(environmentData);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return dbEnvironment;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
private async addPackages(
|
|
892
|
-
environment: Environment,
|
|
893
|
-
environmentId: string,
|
|
894
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
895
|
-
): Promise<void> {
|
|
896
|
-
const packages = await environment.listPackages();
|
|
897
|
-
|
|
898
|
-
// Sync each package
|
|
899
|
-
for (const pkg of packages) {
|
|
900
|
-
if (!pkg.name) {
|
|
901
|
-
logger.warn("Skipping package with undefined name");
|
|
902
|
-
continue;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
await this.addPackage(pkg, environmentId, repository);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
private async addPackage(
|
|
910
|
-
pkg: components["schemas"]["Package"],
|
|
911
|
-
environmentId: string,
|
|
912
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
913
|
-
): Promise<void> {
|
|
914
|
-
const pkgs = pkg as {
|
|
915
|
-
name: string;
|
|
916
|
-
description?: string;
|
|
917
|
-
manifestPath?: string;
|
|
918
|
-
metadata?: Record<string, unknown>;
|
|
919
|
-
};
|
|
920
|
-
|
|
921
|
-
const packageData = {
|
|
922
|
-
environmentId,
|
|
923
|
-
name: pkgs.name,
|
|
924
|
-
description: pkgs.description ?? undefined,
|
|
925
|
-
manifestPath: pkgs.manifestPath ?? "",
|
|
926
|
-
metadata: pkgs.metadata ?? {},
|
|
927
|
-
};
|
|
928
|
-
|
|
929
|
-
try {
|
|
930
|
-
await repository.createPackage(packageData);
|
|
931
|
-
logger.info(`Synced package: ${pkg.name}`);
|
|
932
|
-
} catch (err: unknown) {
|
|
933
|
-
const error = err as Error;
|
|
934
|
-
if (
|
|
935
|
-
error.message?.includes("UNIQUE") ||
|
|
936
|
-
error.message?.includes("Constraint")
|
|
937
|
-
) {
|
|
938
|
-
await this.updatePackage(
|
|
939
|
-
pkgs.name,
|
|
940
|
-
environmentId,
|
|
941
|
-
packageData,
|
|
942
|
-
repository,
|
|
943
|
-
);
|
|
944
|
-
} else {
|
|
945
|
-
logger.warn(`Failed to sync package ${pkg.name}:`, error.message);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
private async updatePackage(
|
|
951
|
-
packageName: string,
|
|
952
|
-
environmentId: string,
|
|
953
|
-
packageData: {
|
|
954
|
-
description: string | undefined;
|
|
955
|
-
manifestPath: string;
|
|
956
|
-
metadata: Record<string, unknown>;
|
|
957
|
-
},
|
|
958
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
959
|
-
): Promise<void> {
|
|
960
|
-
const existingPackage = await repository.getPackageByName(
|
|
961
|
-
environmentId,
|
|
962
|
-
packageName,
|
|
963
|
-
);
|
|
964
|
-
|
|
965
|
-
if (existingPackage) {
|
|
966
|
-
await repository.updatePackage(existingPackage.id, packageData);
|
|
967
|
-
logger.info(`Updated existing package: ${packageName}`);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
private async addConnections(
|
|
972
|
-
environment: Environment,
|
|
973
|
-
environmentId: string,
|
|
974
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
975
|
-
): Promise<void> {
|
|
976
|
-
try {
|
|
977
|
-
const connections = environment.listApiConnections();
|
|
978
|
-
// Add/update connections
|
|
979
|
-
for (const conn of connections) {
|
|
980
|
-
if (!conn.name) {
|
|
981
|
-
logger.warn("Skipping connection with undefined name");
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Check if connection exists
|
|
986
|
-
const existingConn = await repository.getConnectionByName(
|
|
987
|
-
environmentId,
|
|
988
|
-
conn.name,
|
|
989
|
-
);
|
|
990
|
-
|
|
991
|
-
if (existingConn) {
|
|
992
|
-
await this.updateConnection(conn, environmentId, repository);
|
|
993
|
-
} else {
|
|
994
|
-
await this.addConnection(conn, environmentId, repository);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
} catch (err: unknown) {
|
|
998
|
-
const error = err as Error;
|
|
999
|
-
const environmentName = environment.metadata?.name;
|
|
1000
|
-
logger.error(
|
|
1001
|
-
`Error syncing connections for "${environmentName}":`,
|
|
1002
|
-
error,
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
public async addConnection(
|
|
1008
|
-
conn: ReturnType<Environment["listApiConnections"]>[number],
|
|
1009
|
-
environmentId: string,
|
|
1010
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
1011
|
-
): Promise<void> {
|
|
1012
|
-
if (!conn.name) {
|
|
1013
|
-
logger.warn("Skipping connection with undefined name");
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
const connectionData = {
|
|
1018
|
-
environmentId,
|
|
1019
|
-
name: conn.name,
|
|
1020
|
-
type: conn.type as Connection["type"],
|
|
1021
|
-
config: conn,
|
|
1022
|
-
};
|
|
1023
|
-
|
|
1024
|
-
try {
|
|
1025
|
-
await repository.createConnection(connectionData);
|
|
1026
|
-
logger.info(`Created connection: ${conn.name}`);
|
|
1027
|
-
} catch (err: unknown) {
|
|
1028
|
-
const error = err as Error;
|
|
1029
|
-
logger.error(`Failed to create connection ${conn.name}:`, error);
|
|
1030
|
-
throw error;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
public async updateConnection(
|
|
1035
|
-
conn: ReturnType<Environment["listApiConnections"]>[number],
|
|
1036
|
-
environmentId: string,
|
|
1037
|
-
repository: ReturnType<typeof this.storageManager.getRepository>,
|
|
1038
|
-
): Promise<void> {
|
|
1039
|
-
if (!conn.name) {
|
|
1040
|
-
throw new Error("Connection name is required for update");
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
const existingConn = await repository.getConnectionByName(
|
|
1044
|
-
environmentId,
|
|
1045
|
-
conn.name,
|
|
1046
|
-
);
|
|
1047
|
-
|
|
1048
|
-
if (!existingConn) {
|
|
1049
|
-
logger.error(`Connection "${conn.name}" not found in environment`);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
const connectionData = {
|
|
1053
|
-
type: conn.type as Connection["type"],
|
|
1054
|
-
config: conn,
|
|
1055
|
-
};
|
|
1056
|
-
|
|
1057
|
-
try {
|
|
1058
|
-
if (existingConn) {
|
|
1059
|
-
await repository.updateConnection(existingConn.id, connectionData);
|
|
1060
|
-
}
|
|
1061
|
-
logger.info(`Updated connection: ${conn.name}`);
|
|
1062
|
-
} catch (err: unknown) {
|
|
1063
|
-
const error = err as Error;
|
|
1064
|
-
logger.error(`Failed to update connection ${conn.name}:`, error);
|
|
1065
|
-
throw error;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
public async addPackageToDatabase(
|
|
1070
|
-
environmentName: string,
|
|
1071
|
-
packageName: string,
|
|
1072
|
-
): Promise<void> {
|
|
1073
|
-
const environment = await this.getEnvironment(environmentName, false);
|
|
1074
|
-
const repository = this.storageManager.getRepository();
|
|
1075
|
-
|
|
1076
|
-
// Get the environment ID from database
|
|
1077
|
-
const dbEnvironment =
|
|
1078
|
-
await repository.getEnvironmentByName(environmentName);
|
|
1079
|
-
|
|
1080
|
-
if (!dbEnvironment) {
|
|
1081
|
-
logger.error(`Environment "${environmentName}" not found in database`);
|
|
1082
|
-
throw new Error(
|
|
1083
|
-
`Environment "${environmentName}" not found in database`,
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// Get the package from the environment
|
|
1088
|
-
const packages = await environment.listPackages();
|
|
1089
|
-
const pkg = packages.find((p) => p.name === packageName);
|
|
1090
|
-
|
|
1091
|
-
if (!pkg) {
|
|
1092
|
-
logger.warn(`Package "${packageName}" not found in environment`);
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Sync the specific package
|
|
1097
|
-
await this.addPackage(pkg, dbEnvironment.id, repository);
|
|
1098
|
-
logger.info(`Synced package "${packageName}" to database`);
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/**
|
|
1102
|
-
* Delete a package from the database
|
|
1103
|
-
*/
|
|
1104
|
-
public async deletePackageFromDatabase(
|
|
1105
|
-
environmentName: string,
|
|
1106
|
-
packageName: string,
|
|
1107
|
-
): Promise<void> {
|
|
1108
|
-
// Before the metadata lookups: embeddings are written off the
|
|
1109
|
-
// in-memory environment, which can outlive a lost metadata row,
|
|
1110
|
-
// so the cleanup must not sit behind the missing-row early return.
|
|
1111
|
-
this.cleanupPackageEmbeddings(environmentName, packageName);
|
|
1112
|
-
|
|
1113
|
-
const repository = this.storageManager.getRepository();
|
|
1114
|
-
|
|
1115
|
-
// Get the environment ID from database
|
|
1116
|
-
const dbEnvironment =
|
|
1117
|
-
await repository.getEnvironmentByName(environmentName);
|
|
1118
|
-
|
|
1119
|
-
if (!dbEnvironment) {
|
|
1120
|
-
logger.error(`Environment "${environmentName}" not found in database`);
|
|
1121
|
-
return;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Find and delete the package
|
|
1125
|
-
const existingPackage = await repository.getPackageByName(
|
|
1126
|
-
dbEnvironment.id,
|
|
1127
|
-
packageName,
|
|
1128
|
-
);
|
|
1129
|
-
|
|
1130
|
-
if (existingPackage) {
|
|
1131
|
-
await repository.deletePackage(existingPackage.id);
|
|
1132
|
-
logger.info(`Deleted package "${packageName}" from database`);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* Best-effort, non-blocking: drop a deleted package's cached entity
|
|
1138
|
-
* embeddings so package churn does not grow publisher.db forever.
|
|
1139
|
-
* Not awaited by callers: the cleanup queues on the package's sync
|
|
1140
|
-
* mutex, which an in-flight bulk embed can hold for minutes, and a
|
|
1141
|
-
* DELETE response must not stall behind it. Needs only the names, so
|
|
1142
|
-
* it runs regardless of whether the metadata row still exists. An
|
|
1143
|
-
* orphaned row is inert (reads are scoped by environment + package),
|
|
1144
|
-
* so failure is logged, never propagated.
|
|
1145
|
-
*/
|
|
1146
|
-
private cleanupPackageEmbeddings(
|
|
1147
|
-
environmentName: string,
|
|
1148
|
-
packageName: string,
|
|
1149
|
-
): void {
|
|
1150
|
-
try {
|
|
1151
|
-
deletePackageEmbeddings(
|
|
1152
|
-
this.storageManager.getDuckDbConnection(),
|
|
1153
|
-
environmentName,
|
|
1154
|
-
packageName,
|
|
1155
|
-
).catch((error: unknown) => {
|
|
1156
|
-
logger.warn("Failed to clean up entity embeddings for package", {
|
|
1157
|
-
environmentName,
|
|
1158
|
-
packageName,
|
|
1159
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1160
|
-
});
|
|
1161
|
-
});
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
logger.warn("Failed to clean up entity embeddings for package", {
|
|
1164
|
-
environmentName,
|
|
1165
|
-
packageName,
|
|
1166
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1167
|
-
});
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/** Environment-wide variant of {@link cleanupPackageEmbeddings}. */
|
|
1172
|
-
private cleanupEnvironmentEmbeddings(environmentName: string): void {
|
|
1173
|
-
try {
|
|
1174
|
-
deleteEnvironmentEmbeddings(
|
|
1175
|
-
this.storageManager.getDuckDbConnection(),
|
|
1176
|
-
environmentName,
|
|
1177
|
-
).catch((error: unknown) => {
|
|
1178
|
-
logger.warn(
|
|
1179
|
-
"Failed to clean up entity embeddings for environment",
|
|
1180
|
-
{
|
|
1181
|
-
environmentName,
|
|
1182
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1183
|
-
},
|
|
1184
|
-
);
|
|
1185
|
-
});
|
|
1186
|
-
} catch (error) {
|
|
1187
|
-
logger.warn("Failed to clean up entity embeddings for environment", {
|
|
1188
|
-
environmentName,
|
|
1189
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
private async cleanupAndCreatePublisherPath() {
|
|
1195
|
-
const reInit = process.env.INITIALIZE_STORAGE === "true";
|
|
1196
|
-
|
|
1197
|
-
// Ensure serverRootPath exists as a directory (mkdir recursive is a
|
|
1198
|
-
// no-op when the directory already exists and avoids a Bun-on-Windows
|
|
1199
|
-
// bug where fs.promises.stat resolves to undefined instead of throwing)
|
|
1200
|
-
await fs.promises.mkdir(this.serverRootPath, { recursive: true });
|
|
1201
|
-
|
|
1202
|
-
if (reInit) {
|
|
1203
|
-
const uploadDocsPath = path.join(
|
|
1204
|
-
this.serverRootPath,
|
|
1205
|
-
PUBLISHER_DATA_DIR,
|
|
1206
|
-
);
|
|
1207
|
-
logger.info(
|
|
1208
|
-
`Reinitialization mode: Cleaning up upload documents path ${uploadDocsPath}`,
|
|
1209
|
-
);
|
|
1210
|
-
try {
|
|
1211
|
-
await fs.promises.rm(uploadDocsPath, {
|
|
1212
|
-
recursive: true,
|
|
1213
|
-
force: true,
|
|
1214
|
-
});
|
|
1215
|
-
} catch (error) {
|
|
1216
|
-
if ((error as NodeJS.ErrnoException).code === "EACCES") {
|
|
1217
|
-
logger.warn(
|
|
1218
|
-
`Permission denied, skipping cleanup of upload documents path ${uploadDocsPath}`,
|
|
1219
|
-
);
|
|
1220
|
-
} else if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1221
|
-
// Ignore if directory doesn't exist
|
|
1222
|
-
throw error;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
} else {
|
|
1226
|
-
logger.info(`Using existing publisher path`);
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const uploadDocsPath = path.join(this.serverRootPath, PUBLISHER_DATA_DIR);
|
|
1230
|
-
await fs.promises.mkdir(uploadDocsPath, { recursive: true });
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
public async listEnvironments(skipInitializationCheck: boolean = false) {
|
|
1234
|
-
if (!skipInitializationCheck) {
|
|
1235
|
-
await this.finishedInitialization;
|
|
1236
|
-
}
|
|
1237
|
-
return Promise.all(
|
|
1238
|
-
Array.from(this.environments.values()).map((environment) =>
|
|
1239
|
-
environment.serialize(),
|
|
1240
|
-
),
|
|
1241
|
-
);
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
public async getStatus() {
|
|
1245
|
-
// Surface the memory governor's back-pressure as a "throttled"
|
|
1246
|
-
// operational state so the control plane can stop routing new package
|
|
1247
|
-
// loads/queries to a throttled worker. Draining takes precedence: a
|
|
1248
|
-
// shutting-down server should keep reporting "draining". When the
|
|
1249
|
-
// governor is disabled (no PUBLISHER_MAX_MEMORY_BYTES) this never fires.
|
|
1250
|
-
const baseState = getOperationalState();
|
|
1251
|
-
const operationalState = (
|
|
1252
|
-
baseState !== "draining" &&
|
|
1253
|
-
(this.memoryGovernor?.isBackpressured() ?? false)
|
|
1254
|
-
? "throttled"
|
|
1255
|
-
: baseState
|
|
1256
|
-
) as components["schemas"]["ServerStatus"]["operationalState"];
|
|
1257
|
-
|
|
1258
|
-
const status: {
|
|
1259
|
-
timestamp: number;
|
|
1260
|
-
environments: Array<components["schemas"]["Environment"]>;
|
|
1261
|
-
initialized: boolean;
|
|
1262
|
-
frozenConfig: boolean;
|
|
1263
|
-
operationalState: components["schemas"]["ServerStatus"]["operationalState"];
|
|
1264
|
-
loadErrors?: LoadError[];
|
|
1265
|
-
} = {
|
|
1266
|
-
timestamp: Date.now(),
|
|
1267
|
-
environments: [],
|
|
1268
|
-
initialized: this.isInitialized,
|
|
1269
|
-
frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
|
|
1270
|
-
operationalState,
|
|
1271
|
-
};
|
|
1272
|
-
|
|
1273
|
-
const environments = await this.listEnvironments(true);
|
|
1274
|
-
|
|
1275
|
-
await Promise.all(
|
|
1276
|
-
environments.map(async (environment) => {
|
|
1277
|
-
try {
|
|
1278
|
-
const packages = environment.packages;
|
|
1279
|
-
const connections = environment.connections;
|
|
1280
|
-
|
|
1281
|
-
logger.debug(`Environment ${environment.name} status:`, {
|
|
1282
|
-
connectionsCount: environment.connections?.length || 0,
|
|
1283
|
-
packagesCount: packages?.length || 0,
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
const _connections = connections?.map((connection) => {
|
|
1287
|
-
return {
|
|
1288
|
-
...connection,
|
|
1289
|
-
attributes: undefined,
|
|
1290
|
-
};
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
const _environment = {
|
|
1294
|
-
...environment,
|
|
1295
|
-
connections: _connections,
|
|
1296
|
-
};
|
|
1297
|
-
environment.connections = _connections;
|
|
1298
|
-
status.environments.push(_environment);
|
|
1299
|
-
} catch (error) {
|
|
1300
|
-
logger.error("Error listing packages and connections", {
|
|
1301
|
-
error,
|
|
1302
|
-
});
|
|
1303
|
-
throw new Error(
|
|
1304
|
-
"Error listing packages and connections: " + error,
|
|
1305
|
-
);
|
|
1306
|
-
}
|
|
1307
|
-
}),
|
|
1308
|
-
);
|
|
1309
|
-
|
|
1310
|
-
// Collected AFTER listEnvironments, not before: serialize() awaits
|
|
1311
|
-
// listPackages(), which is what loads an environment's packages and so
|
|
1312
|
-
// records their failures. Reading the maps at the top of this method risks
|
|
1313
|
-
// reading them before the failures they report have been recorded.
|
|
1314
|
-
//
|
|
1315
|
-
// A read-time overlay over live state, like "throttled" above, so there is
|
|
1316
|
-
// no fourth stored status to keep in sync.
|
|
1317
|
-
const loadErrors: LoadError[] = [];
|
|
1318
|
-
for (const [environmentName, message] of this.failedEnvironments) {
|
|
1319
|
-
loadErrors.push({ environment: environmentName, message });
|
|
1320
|
-
}
|
|
1321
|
-
for (const [environmentName, environment] of this.environments) {
|
|
1322
|
-
for (const [packageName, message] of environment.getFailedPackages()) {
|
|
1323
|
-
loadErrors.push({
|
|
1324
|
-
environment: environmentName,
|
|
1325
|
-
package: packageName,
|
|
1326
|
-
message,
|
|
1327
|
-
});
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
// Left unset when nothing failed, so the key is absent from the response
|
|
1331
|
-
// and a healthy server's status body is byte-for-byte what it was before
|
|
1332
|
-
// this field existed.
|
|
1333
|
-
if (loadErrors.length > 0) {
|
|
1334
|
-
status.loadErrors = loadErrors;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
return status;
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
public async getEnvironment(
|
|
1341
|
-
environmentName: string,
|
|
1342
|
-
reload: boolean = false,
|
|
1343
|
-
): Promise<Environment> {
|
|
1344
|
-
await this.finishedInitialization;
|
|
1345
|
-
assertSafePackageName(environmentName);
|
|
1346
|
-
|
|
1347
|
-
// Check if environment is already loaded first
|
|
1348
|
-
const environment = this.environments.get(environmentName);
|
|
1349
|
-
if (environment !== undefined && !reload) {
|
|
1350
|
-
return environment;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// We need to acquire the mutex to prevent concurrent requests from creating the
|
|
1354
|
-
// environment multiple times.
|
|
1355
|
-
let environmentMutex = this.environmentMutexes.get(environmentName);
|
|
1356
|
-
if (environmentMutex?.isLocked()) {
|
|
1357
|
-
await environmentMutex.waitForUnlock();
|
|
1358
|
-
const existingEnvironment = this.environments.get(environmentName);
|
|
1359
|
-
if (existingEnvironment && !reload) {
|
|
1360
|
-
return existingEnvironment;
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
environmentMutex = new Mutex();
|
|
1364
|
-
this.environmentMutexes.set(environmentName, environmentMutex);
|
|
1365
|
-
|
|
1366
|
-
return environmentMutex.runExclusive(async () => {
|
|
1367
|
-
// Double-check after acquiring mutex
|
|
1368
|
-
const existingEnvironment = this.environments.get(environmentName);
|
|
1369
|
-
if (existingEnvironment !== undefined && !reload) {
|
|
1370
|
-
return existingEnvironment;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const environmentManifest =
|
|
1374
|
-
await EnvironmentStore.reloadEnvironmentManifest(
|
|
1375
|
-
this.serverRootPath,
|
|
1376
|
-
);
|
|
1377
|
-
const environmentConfig = environmentManifest.environments.find(
|
|
1378
|
-
(e) => e.name === environmentName,
|
|
1379
|
-
);
|
|
1380
|
-
const environmentPath =
|
|
1381
|
-
existingEnvironment?.metadata.location ||
|
|
1382
|
-
environmentConfig?.packages[0]?.location;
|
|
1383
|
-
if (!environmentPath) {
|
|
1384
|
-
throw new EnvironmentNotFoundError(
|
|
1385
|
-
`Environment "${environmentName}" could not be resolved to a path.`,
|
|
1386
|
-
);
|
|
1387
|
-
}
|
|
1388
|
-
return await this.addEnvironment({
|
|
1389
|
-
name: environmentName,
|
|
1390
|
-
resource: `${API_PREFIX}/environments/${environmentName}`,
|
|
1391
|
-
connections: environmentConfig?.connections || [],
|
|
1392
|
-
});
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
public async addEnvironment(
|
|
1397
|
-
environment: ApiEnvironment,
|
|
1398
|
-
skipInitialization: boolean = false,
|
|
1399
|
-
) {
|
|
1400
|
-
if (!skipInitialization) {
|
|
1401
|
-
await this.finishedInitialization;
|
|
1402
|
-
}
|
|
1403
|
-
if (!skipInitialization && this.publisherConfigIsFrozen) {
|
|
1404
|
-
throw new FrozenConfigError();
|
|
1405
|
-
}
|
|
1406
|
-
assertSafePackageName(environment.name);
|
|
1407
|
-
const environmentName = environment.name;
|
|
1408
|
-
for (const _package of environment.packages || []) {
|
|
1409
|
-
assertSafePackageName(_package.name);
|
|
1410
|
-
}
|
|
1411
|
-
// Check if environment already exists and update it instead of creating a new one
|
|
1412
|
-
const existingEnvironment = this.environments.get(environmentName);
|
|
1413
|
-
if (existingEnvironment) {
|
|
1414
|
-
const updatedEnvironment =
|
|
1415
|
-
await existingEnvironment.update(environment);
|
|
1416
|
-
this.environments.set(environmentName, updatedEnvironment);
|
|
1417
|
-
await this.addEnvironmentToDatabase(updatedEnvironment);
|
|
1418
|
-
return updatedEnvironment;
|
|
1419
|
-
}
|
|
1420
|
-
const environmentManifest =
|
|
1421
|
-
await EnvironmentStore.reloadEnvironmentManifest(this.serverRootPath);
|
|
1422
|
-
const environmentConfig = environmentManifest.environments.find(
|
|
1423
|
-
(e) => e.name === environmentName,
|
|
1424
|
-
);
|
|
1425
|
-
const hasPackages =
|
|
1426
|
-
(environment?.packages && environment.packages.length > 0) ||
|
|
1427
|
-
(environmentConfig?.packages && environmentConfig.packages.length > 0);
|
|
1428
|
-
let absoluteEnvironmentPath: string;
|
|
1429
|
-
let mountErrors: ReadonlyMap<string, string> = new Map();
|
|
1430
|
-
if (hasPackages) {
|
|
1431
|
-
const packagesToProcess =
|
|
1432
|
-
environment?.packages || environmentConfig?.packages || [];
|
|
1433
|
-
const loaded = await this.loadEnvironmentIntoDisk(
|
|
1434
|
-
environmentName,
|
|
1435
|
-
packagesToProcess,
|
|
1436
|
-
);
|
|
1437
|
-
absoluteEnvironmentPath = loaded.path;
|
|
1438
|
-
mountErrors = loaded.mountErrors;
|
|
1439
|
-
if (absoluteEnvironmentPath.endsWith(".zip")) {
|
|
1440
|
-
absoluteEnvironmentPath = await this.unzipEnvironment(
|
|
1441
|
-
absoluteEnvironmentPath,
|
|
1442
|
-
);
|
|
1443
|
-
}
|
|
1444
|
-
} else {
|
|
1445
|
-
absoluteEnvironmentPath = await this.scaffoldEnvironment(environment);
|
|
1446
|
-
}
|
|
1447
|
-
const newEnvironment = await Environment.create(
|
|
1448
|
-
environmentName,
|
|
1449
|
-
absoluteEnvironmentPath,
|
|
1450
|
-
environment.connections || [],
|
|
1451
|
-
);
|
|
1452
|
-
newEnvironment.setMemoryGovernor(this.memoryGovernor);
|
|
1453
|
-
|
|
1454
|
-
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
1455
|
-
newEnvironment.metadata.location = absoluteEnvironmentPath;
|
|
1456
|
-
|
|
1457
|
-
// Attach before the SERVING seeding below: those packages are configured
|
|
1458
|
-
// but un-mounted, and this is the only record of why.
|
|
1459
|
-
for (const [packageName, message] of mountErrors) {
|
|
1460
|
-
newEnvironment.setPackageMountError(packageName, message);
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
this.environments.set(environmentName, newEnvironment);
|
|
1464
|
-
// It loaded, so any earlier failure for this name is stale. A boot
|
|
1465
|
-
// failure can be followed by a successful POST or lazy load of the same
|
|
1466
|
-
// environment, and a status that keeps reporting the old error would lie
|
|
1467
|
-
// in the other direction.
|
|
1468
|
-
this.failedEnvironments.delete(environmentName);
|
|
1469
|
-
|
|
1470
|
-
environment?.packages?.forEach((_package) => {
|
|
1471
|
-
if (_package.name) {
|
|
1472
|
-
newEnvironment.setPackageStatus(
|
|
1473
|
-
_package.name,
|
|
1474
|
-
PackageStatus.SERVING,
|
|
1475
|
-
);
|
|
1476
|
-
}
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
await this.addEnvironmentToDatabase(newEnvironment);
|
|
1480
|
-
|
|
1481
|
-
return newEnvironment;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
public async unzipEnvironment(absoluteEnvironmentPath: string) {
|
|
1485
|
-
assertSafeEnvironmentPath(absoluteEnvironmentPath);
|
|
1486
|
-
const startedAt = Date.now();
|
|
1487
|
-
logger.info(
|
|
1488
|
-
`Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
|
|
1489
|
-
);
|
|
1490
|
-
const unzippedEnvironmentPath = absoluteEnvironmentPath.replace(
|
|
1491
|
-
".zip",
|
|
1492
|
-
"",
|
|
1493
|
-
);
|
|
1494
|
-
await fs.promises.rm(unzippedEnvironmentPath, {
|
|
1495
|
-
recursive: true,
|
|
1496
|
-
force: true,
|
|
1497
|
-
});
|
|
1498
|
-
await fs.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
|
|
1499
|
-
|
|
1500
|
-
// Stream-extract via yauzl (wrapped by extract-zip). Each entry's
|
|
1501
|
-
// inflate and write are dispatched to the libuv thread pool, so the
|
|
1502
|
-
// main event loop stays responsive even for very large archives.
|
|
1503
|
-
// The previous adm-zip path used fs.readFileSync + zlib.inflateRawSync
|
|
1504
|
-
// on the main thread, which parked the loop long enough on multi-
|
|
1505
|
-
// hundred-MB packages to fail Kubernetes liveness probes mid-extract.
|
|
1506
|
-
let entryCount = 0;
|
|
1507
|
-
let totalUncompressedBytes = 0;
|
|
1508
|
-
await extract(absoluteEnvironmentPath, {
|
|
1509
|
-
dir: path.resolve(unzippedEnvironmentPath),
|
|
1510
|
-
onEntry: (entry) => {
|
|
1511
|
-
entryCount += 1;
|
|
1512
|
-
totalUncompressedBytes += entry.uncompressedSize ?? 0;
|
|
1513
|
-
},
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
const mib = (totalUncompressedBytes / (1024 * 1024)).toFixed(1);
|
|
1517
|
-
logger.info(
|
|
1518
|
-
`Unzipped "${absoluteEnvironmentPath}" -> "${unzippedEnvironmentPath}" ` +
|
|
1519
|
-
`(${entryCount} entries, ${mib} MiB uncompressed) in ` +
|
|
1520
|
-
`${formatDuration(Date.now() - startedAt)}`,
|
|
1521
|
-
);
|
|
1522
|
-
|
|
1523
|
-
return unzippedEnvironmentPath;
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
public async updateEnvironment(environment: ApiEnvironment) {
|
|
1527
|
-
await this.finishedInitialization;
|
|
1528
|
-
if (this.publisherConfigIsFrozen) {
|
|
1529
|
-
throw new FrozenConfigError();
|
|
1530
|
-
}
|
|
1531
|
-
validateEnvironmentAzureUrls(environment);
|
|
1532
|
-
assertSafePackageName(environment.name);
|
|
1533
|
-
const environmentName = environment.name;
|
|
1534
|
-
for (const _package of environment.packages || []) {
|
|
1535
|
-
assertSafePackageName(_package.name);
|
|
1536
|
-
}
|
|
1537
|
-
const existingEnvironment = this.environments.get(environmentName);
|
|
1538
|
-
if (!existingEnvironment) {
|
|
1539
|
-
throw new EnvironmentNotFoundError(
|
|
1540
|
-
`Environment ${environmentName} not found`,
|
|
1541
|
-
);
|
|
1542
|
-
}
|
|
1543
|
-
const updatedEnvironment = await existingEnvironment.update(environment);
|
|
1544
|
-
this.environments.set(environmentName, updatedEnvironment);
|
|
1545
|
-
await this.addEnvironmentToDatabase(updatedEnvironment);
|
|
1546
|
-
return updatedEnvironment;
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
public async deleteEnvironment(
|
|
1550
|
-
environmentName: string,
|
|
1551
|
-
): Promise<Environment | undefined> {
|
|
1552
|
-
await this.finishedInitialization;
|
|
1553
|
-
if (this.publisherConfigIsFrozen) {
|
|
1554
|
-
throw new FrozenConfigError();
|
|
1555
|
-
}
|
|
1556
|
-
assertSafePackageName(environmentName);
|
|
1557
|
-
const environment = this.environments.get(environmentName);
|
|
1558
|
-
if (!environment) {
|
|
1559
|
-
return;
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
const environmentPath = environment.metadata?.location;
|
|
1563
|
-
|
|
1564
|
-
// Close all connections before removing the environment
|
|
1565
|
-
await environment.closeAllConnections();
|
|
1566
|
-
|
|
1567
|
-
this.environments.delete(environmentName);
|
|
1568
|
-
await this.deleteEnvironmentFromDatabase(environmentName);
|
|
1569
|
-
if (environmentPath) {
|
|
1570
|
-
try {
|
|
1571
|
-
await fs.promises.rm(environmentPath, {
|
|
1572
|
-
recursive: true,
|
|
1573
|
-
force: true,
|
|
1574
|
-
});
|
|
1575
|
-
logger.info(`Deleted environment directory: ${environmentPath}`);
|
|
1576
|
-
} catch (err) {
|
|
1577
|
-
logger.error("Error removing environment directory", {
|
|
1578
|
-
error: err,
|
|
1579
|
-
});
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
return environment;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
public static async reloadEnvironmentManifest(
|
|
1587
|
-
serverRootPath: string,
|
|
1588
|
-
): Promise<ProcessedPublisherConfig> {
|
|
1589
|
-
try {
|
|
1590
|
-
return getProcessedPublisherConfig(serverRootPath);
|
|
1591
|
-
} catch (error) {
|
|
1592
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1593
|
-
logger.error(
|
|
1594
|
-
`Error reading ${PUBLISHER_CONFIG_NAME}. Generating from directory`,
|
|
1595
|
-
{ error },
|
|
1596
|
-
);
|
|
1597
|
-
return { frozenConfig: false, environments: [] };
|
|
1598
|
-
} else {
|
|
1599
|
-
// If publisher.config.json is missing, generate the manifest from directories
|
|
1600
|
-
try {
|
|
1601
|
-
const entries = await fs.promises.readdir(serverRootPath, {
|
|
1602
|
-
withFileTypes: true,
|
|
1603
|
-
});
|
|
1604
|
-
const environments: ProcessedEnvironment[] = [];
|
|
1605
|
-
for (const entry of entries) {
|
|
1606
|
-
if (entry.isDirectory()) {
|
|
1607
|
-
environments.push({
|
|
1608
|
-
name: entry.name,
|
|
1609
|
-
packages: [
|
|
1610
|
-
{
|
|
1611
|
-
name: entry.name,
|
|
1612
|
-
location: `./${entry.name}` as const,
|
|
1613
|
-
},
|
|
1614
|
-
],
|
|
1615
|
-
connections: [],
|
|
1616
|
-
});
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
return { frozenConfig: false, environments };
|
|
1620
|
-
} catch (lsError) {
|
|
1621
|
-
logger.error(`Error listing directories in ${serverRootPath}`, {
|
|
1622
|
-
error: lsError,
|
|
1623
|
-
});
|
|
1624
|
-
return { frozenConfig: false, environments: [] };
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
private async scaffoldEnvironment(environment: ApiEnvironment) {
|
|
1631
|
-
assertSafePackageName(environment.name);
|
|
1632
|
-
const environmentName = environment.name;
|
|
1633
|
-
const absoluteEnvironmentPath = safeJoinUnderRoot(
|
|
1634
|
-
this.serverRootPath,
|
|
1635
|
-
PUBLISHER_DATA_DIR,
|
|
1636
|
-
environmentName,
|
|
1637
|
-
);
|
|
1638
|
-
await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
|
|
1639
|
-
if (environment.readme) {
|
|
1640
|
-
await fs.promises.writeFile(
|
|
1641
|
-
safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
|
|
1642
|
-
environment.readme,
|
|
1643
|
-
);
|
|
1644
|
-
}
|
|
1645
|
-
return absoluteEnvironmentPath;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
private isLocalPath(location: string) {
|
|
1649
|
-
return (
|
|
1650
|
-
location.startsWith("./") ||
|
|
1651
|
-
location.startsWith("../") ||
|
|
1652
|
-
location.startsWith("~/") ||
|
|
1653
|
-
location.startsWith("/") ||
|
|
1654
|
-
path.isAbsolute(location)
|
|
1655
|
-
);
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
/**
|
|
1659
|
-
* Absolute on-disk path for a local package `location`, anchored at the
|
|
1660
|
-
* directory holding the active config. That covers locations POSTed at
|
|
1661
|
-
* runtime too, which no config declares. When there is no config worth
|
|
1662
|
-
* anchoring to, `getPublisherConfigDir` returns null (it owns the cases) and
|
|
1663
|
-
* the server root is the only meaningful base, which is also what this did
|
|
1664
|
-
* before the anchor moved.
|
|
1665
|
-
*/
|
|
1666
|
-
private resolveLocalPath(location: string): string {
|
|
1667
|
-
return resolvePackageLocation(
|
|
1668
|
-
location,
|
|
1669
|
-
getPublisherConfigDir(this.serverRootPath) ?? this.serverRootPath,
|
|
1670
|
-
);
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
private isGitHubURL(location: string) {
|
|
1674
|
-
return (
|
|
1675
|
-
location.startsWith("https://github.com/") ||
|
|
1676
|
-
location.startsWith("git@github.com:")
|
|
1677
|
-
);
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
private isGCSURL(location: string) {
|
|
1681
|
-
return location.startsWith("gs://");
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
private isS3URL(location: string) {
|
|
1685
|
-
return location.startsWith("s3://");
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
/**
|
|
1689
|
-
* Isolate a location failure to the packages it actually affects instead of
|
|
1690
|
-
* aborting the whole environment. The packages that mounted still serve;
|
|
1691
|
-
* each one named here is left un-mounted, so its configured status entry
|
|
1692
|
-
* (set in addEnvironment) resolves to a per-package load failure that
|
|
1693
|
-
* getFailedPackages() -> /status loadErrors reports. A bad location now
|
|
1694
|
-
* behaves like a bad manifest: those packages dropped, siblings unaffected.
|
|
1695
|
-
*
|
|
1696
|
-
* The message is kept rather than letting the lazy load speak for it. An
|
|
1697
|
-
* un-mounted package fails later on the manifest that was never copied, and
|
|
1698
|
-
* "Package manifest ... does not exist." points at publisher_data/ instead
|
|
1699
|
-
* of the location that is actually wrong. Redacted because it reaches an
|
|
1700
|
-
* HTTP response body.
|
|
1701
|
-
*/
|
|
1702
|
-
private recordMountFailure(
|
|
1703
|
-
error: unknown,
|
|
1704
|
-
location: string,
|
|
1705
|
-
packageNames: string[],
|
|
1706
|
-
mountErrors: Map<string, string>,
|
|
1707
|
-
): void {
|
|
1708
|
-
logger.error(
|
|
1709
|
-
`Failed to download or mount location "${location}"`,
|
|
1710
|
-
this.extractErrorDataFromError(error),
|
|
1711
|
-
);
|
|
1712
|
-
const message = redactPgSecrets(
|
|
1713
|
-
error instanceof Error ? error.message : String(error),
|
|
1714
|
-
);
|
|
1715
|
-
for (const packageName of packageNames) {
|
|
1716
|
-
mountErrors.set(packageName, message);
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
private async loadEnvironmentIntoDisk(
|
|
1721
|
-
environmentName: string,
|
|
1722
|
-
packages: ApiEnvironment["packages"],
|
|
1723
|
-
) {
|
|
1724
|
-
assertSafePackageName(environmentName);
|
|
1725
|
-
const absoluteTargetPath = safeJoinUnderRoot(
|
|
1726
|
-
this.serverRootPath,
|
|
1727
|
-
PUBLISHER_DATA_DIR,
|
|
1728
|
-
environmentName,
|
|
1729
|
-
);
|
|
1730
|
-
// Why each package's location failed, carried out to the Environment so
|
|
1731
|
-
// /status can report the real cause rather than the missing manifest it
|
|
1732
|
-
// leaves behind.
|
|
1733
|
-
const mountErrors = new Map<string, string>();
|
|
1734
|
-
|
|
1735
|
-
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
1736
|
-
|
|
1737
|
-
if (!packages || packages.length === 0) {
|
|
1738
|
-
throw new PackageNotFoundError(
|
|
1739
|
-
`No packages found for environment ${environmentName}`,
|
|
1740
|
-
);
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// Group packages by location to optimize downloads
|
|
1744
|
-
const locationGroups = new Map<
|
|
1745
|
-
string,
|
|
1746
|
-
Array<{ name: string; location: string }>
|
|
1747
|
-
>();
|
|
1748
|
-
|
|
1749
|
-
for (const _package of packages) {
|
|
1750
|
-
if (!_package.name) {
|
|
1751
|
-
throw new PackageNotFoundError(`Package has no name specified`);
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
if (!_package.location) {
|
|
1755
|
-
throw new PackageNotFoundError(
|
|
1756
|
-
`Package ${_package.name} has no location specified`,
|
|
1757
|
-
);
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
// For GitHub URLs, group by base repository URL to optimize downloads
|
|
1761
|
-
let locationKey = _package.location;
|
|
1762
|
-
if (this.isGitHubURL(_package.location)) {
|
|
1763
|
-
const githubInfo = parseGitHubUrl(_package.location);
|
|
1764
|
-
if (githubInfo) {
|
|
1765
|
-
// Always use HTTPS format for grouping to ensure consistency
|
|
1766
|
-
locationKey = `https://github.com/${githubInfo.owner}/${githubInfo.repoName}`;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
if (!locationGroups.has(locationKey)) {
|
|
1771
|
-
locationGroups.set(locationKey, []);
|
|
1772
|
-
}
|
|
1773
|
-
locationGroups.get(locationKey)!.push({
|
|
1774
|
-
name: _package.name,
|
|
1775
|
-
location: _package.location,
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
// Processing by each unique location
|
|
1780
|
-
const totalPackages = packages.length;
|
|
1781
|
-
let mountedCount = 0;
|
|
1782
|
-
for (const [groupedLocation, packagesForLocation] of locationGroups) {
|
|
1783
|
-
// Use a hash instead of base64 to keep paths short (Windows MAX_PATH limit)
|
|
1784
|
-
// This works for both local and remote paths
|
|
1785
|
-
const locationHash = crypto
|
|
1786
|
-
.createHash("sha256")
|
|
1787
|
-
.update(groupedLocation)
|
|
1788
|
-
.digest("hex")
|
|
1789
|
-
.substring(0, 16); // Use first 16 chars for shorter paths
|
|
1790
|
-
const tempDownloadPath = safeJoinUnderRoot(
|
|
1791
|
-
absoluteTargetPath,
|
|
1792
|
-
`.temp_${locationHash}`,
|
|
1793
|
-
);
|
|
1794
|
-
await fs.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
1795
|
-
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
|
1796
|
-
// Two failure scopes, deliberately separate. The download is shared by
|
|
1797
|
-
// every package at this location, so losing it loses all of them. An
|
|
1798
|
-
// extract is per package, so one bad sibling must not strand the ones
|
|
1799
|
-
// after it in the loop.
|
|
1800
|
-
let downloaded = true;
|
|
1801
|
-
try {
|
|
1802
|
-
// Use the existing download method for all locations
|
|
1803
|
-
await this.downloadOrMountLocation(
|
|
1804
|
-
groupedLocation,
|
|
1805
|
-
tempDownloadPath,
|
|
1806
|
-
environmentName,
|
|
1807
|
-
"shared",
|
|
1808
|
-
packagesForLocation.map((p) => p.name),
|
|
1809
|
-
);
|
|
1810
|
-
} catch (error) {
|
|
1811
|
-
downloaded = false;
|
|
1812
|
-
this.recordMountFailure(
|
|
1813
|
-
error,
|
|
1814
|
-
groupedLocation,
|
|
1815
|
-
packagesForLocation.map((p) => p.name),
|
|
1816
|
-
mountErrors,
|
|
1817
|
-
);
|
|
1818
|
-
}
|
|
1819
|
-
// Extract each package from the downloaded content
|
|
1820
|
-
for (const _package of downloaded ? packagesForLocation : []) {
|
|
1821
|
-
// Declared out here so the catch can clean up the exact path this
|
|
1822
|
-
// iteration created. Never re-derive it from the package name
|
|
1823
|
-
// there: the name may be why we are in the catch at all, and
|
|
1824
|
-
// safeJoinUnderRoot permits a name that resolves to the root, so a
|
|
1825
|
-
// package called "." would hand the cleanup the whole environment
|
|
1826
|
-
// directory. Undefined means nothing was created yet.
|
|
1827
|
-
let absolutePackagePath: string | undefined;
|
|
1828
|
-
try {
|
|
1829
|
-
const packageDir = _package.name;
|
|
1830
|
-
assertSafePackageName(packageDir);
|
|
1831
|
-
absolutePackagePath = safeJoinUnderRoot(
|
|
1832
|
-
absoluteTargetPath,
|
|
1833
|
-
packageDir,
|
|
1834
|
-
);
|
|
1835
|
-
// For GitHub URLs, extract the subdirectory path from the original location
|
|
1836
|
-
let sourcePath: string;
|
|
1837
|
-
if (this.isGitHubURL(_package.location)) {
|
|
1838
|
-
const githubInfo = parseGitHubUrl(_package.location);
|
|
1839
|
-
if (githubInfo && githubInfo.packagePath) {
|
|
1840
|
-
// Extract subdirectory from the original GitHub URL
|
|
1841
|
-
// Handle both /tree/main/subdir and /tree/branch/subdir cases
|
|
1842
|
-
const subPathMatch =
|
|
1843
|
-
_package.location.match(/\/tree\/[^/]+\/(.+)$/);
|
|
1844
|
-
if (subPathMatch) {
|
|
1845
|
-
sourcePath = safeJoinUnderRoot(
|
|
1846
|
-
tempDownloadPath,
|
|
1847
|
-
subPathMatch[1],
|
|
1848
|
-
);
|
|
1849
|
-
} else {
|
|
1850
|
-
// If no subdirectory after /tree/branch, the repo itself is the package
|
|
1851
|
-
sourcePath = tempDownloadPath;
|
|
1852
|
-
}
|
|
1853
|
-
} else {
|
|
1854
|
-
// No packagePath means the repo itself is the package
|
|
1855
|
-
sourcePath = tempDownloadPath;
|
|
1856
|
-
}
|
|
1857
|
-
} else {
|
|
1858
|
-
// For non-GitHub locations, use package name
|
|
1859
|
-
if (this.isLocalPath(_package.location)) {
|
|
1860
|
-
// Same resolution rule as `downloadOrMountLocation`.
|
|
1861
|
-
// Without this step the existing-source check below
|
|
1862
|
-
// falls through for any relative location, and the
|
|
1863
|
-
// in-place mount branch is unreachable.
|
|
1864
|
-
sourcePath = this.resolveLocalPath(_package.location);
|
|
1865
|
-
} else {
|
|
1866
|
-
sourcePath = safeJoinUnderRoot(
|
|
1867
|
-
tempDownloadPath,
|
|
1868
|
-
groupedLocation,
|
|
1869
|
-
);
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
const sourceExists = await fs.promises
|
|
1874
|
-
.access(sourcePath)
|
|
1875
|
-
.then(() => true)
|
|
1876
|
-
.catch(() => false);
|
|
1877
|
-
|
|
1878
|
-
if (sourceExists) {
|
|
1879
|
-
// In-place mount path (watch-mode-only, local-dir packages
|
|
1880
|
-
// only). Replace the recursive copy with a symlink so that
|
|
1881
|
-
// edits to the source repo are visible to Publisher and
|
|
1882
|
-
// can trigger live-reload events.
|
|
1883
|
-
//
|
|
1884
|
-
// Carefully: we MUST distinguish symlinks from real dirs
|
|
1885
|
-
// when removing the prior content, because `fs.rm`'s
|
|
1886
|
-
// recursive mode could in principle traverse into a
|
|
1887
|
-
// symlinked source dir and damage it. We lstat first and
|
|
1888
|
-
// call `unlink` for symlinks, `rm` for real directories.
|
|
1889
|
-
const isInPlace =
|
|
1890
|
-
this.inPlaceEnvs.has(environmentName) &&
|
|
1891
|
-
this.isLocalPath(_package.location);
|
|
1892
|
-
if (isInPlace) {
|
|
1893
|
-
await clearMountTarget(absolutePackagePath);
|
|
1894
|
-
const absoluteSourcePath = path.resolve(sourcePath);
|
|
1895
|
-
// On Windows a "dir" symlink needs elevation
|
|
1896
|
-
// (SeCreateSymbolicLinkPrivilege — admin or Developer
|
|
1897
|
-
// Mode); a junction does not and works for absolute
|
|
1898
|
-
// directory targets, so prefer it there. chokidar watches
|
|
1899
|
-
// through both.
|
|
1900
|
-
const linkType =
|
|
1901
|
-
process.platform === "win32" ? "junction" : "dir";
|
|
1902
|
-
try {
|
|
1903
|
-
await fs.promises.symlink(
|
|
1904
|
-
absoluteSourcePath,
|
|
1905
|
-
absolutePackagePath,
|
|
1906
|
-
linkType,
|
|
1907
|
-
);
|
|
1908
|
-
logger.info(
|
|
1909
|
-
`In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}" (${++mountedCount}/${totalPackages})`,
|
|
1910
|
-
);
|
|
1911
|
-
} catch (linkError) {
|
|
1912
|
-
// Degrade gracefully instead of failing the env load:
|
|
1913
|
-
// copy the package so it still serves. Live reload
|
|
1914
|
-
// won't work for it (source edits aren't seen), but the
|
|
1915
|
-
// environment comes up. Hit e.g. on locked-down Windows
|
|
1916
|
-
// where even junction creation is denied.
|
|
1917
|
-
const code =
|
|
1918
|
-
(linkError as NodeJS.ErrnoException)?.code ??
|
|
1919
|
-
String(linkError);
|
|
1920
|
-
logger.warn(
|
|
1921
|
-
`In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`,
|
|
1922
|
-
);
|
|
1923
|
-
await clearMountTarget(absolutePackagePath);
|
|
1924
|
-
await fs.promises.mkdir(absolutePackagePath, {
|
|
1925
|
-
recursive: true,
|
|
1926
|
-
});
|
|
1927
|
-
await fs.promises.cp(sourcePath, absolutePackagePath, {
|
|
1928
|
-
recursive: true,
|
|
1929
|
-
});
|
|
1930
|
-
logger.info(
|
|
1931
|
-
`Copied package "${packageDir}" (${++mountedCount}/${totalPackages})`,
|
|
1932
|
-
);
|
|
1933
|
-
}
|
|
1934
|
-
} else {
|
|
1935
|
-
if (
|
|
1936
|
-
this.inPlaceEnvs.has(environmentName) &&
|
|
1937
|
-
!this.isLocalPath(_package.location)
|
|
1938
|
-
) {
|
|
1939
|
-
logger.warn(
|
|
1940
|
-
`Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`,
|
|
1941
|
-
);
|
|
1942
|
-
}
|
|
1943
|
-
// Copy the specific directory. Clear any stale mount target
|
|
1944
|
-
// first (e.g. a symlink left by a prior --watch-env run), or
|
|
1945
|
-
// fs.cp would throw ERR_FS_CP_DIR_TO_NON_DIR onto it.
|
|
1946
|
-
await clearMountTarget(absolutePackagePath);
|
|
1947
|
-
await fs.promises.mkdir(absolutePackagePath, {
|
|
1948
|
-
recursive: true,
|
|
1949
|
-
});
|
|
1950
|
-
await fs.promises.cp(sourcePath, absolutePackagePath, {
|
|
1951
|
-
recursive: true,
|
|
1952
|
-
});
|
|
1953
|
-
logger.info(
|
|
1954
|
-
`Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"} (${++mountedCount}/${totalPackages})`,
|
|
1955
|
-
);
|
|
1956
|
-
}
|
|
1957
|
-
} else {
|
|
1958
|
-
// If source doesn't exist, copy the entire download as the package
|
|
1959
|
-
await clearMountTarget(absolutePackagePath);
|
|
1960
|
-
await fs.promises.mkdir(absolutePackagePath, {
|
|
1961
|
-
recursive: true,
|
|
1962
|
-
});
|
|
1963
|
-
await fs.promises.cp(tempDownloadPath, absolutePackagePath, {
|
|
1964
|
-
recursive: true,
|
|
1965
|
-
});
|
|
1966
|
-
logger.info(
|
|
1967
|
-
`Copied entire download as package "${packageDir}" (${++mountedCount}/${totalPackages})`,
|
|
1968
|
-
);
|
|
1969
|
-
}
|
|
1970
|
-
} catch (error) {
|
|
1971
|
-
this.recordMountFailure(
|
|
1972
|
-
error,
|
|
1973
|
-
groupedLocation,
|
|
1974
|
-
[_package.name],
|
|
1975
|
-
mountErrors,
|
|
1976
|
-
);
|
|
1977
|
-
// Leave nothing behind. A copy that threw part way through can
|
|
1978
|
-
// leave a tree holding the manifest and models but not the data,
|
|
1979
|
-
// which loads cleanly and therefore clears the very error
|
|
1980
|
-
// recorded above, so the package serves incomplete content and
|
|
1981
|
-
// /status says nothing. Continuing past the failure is what
|
|
1982
|
-
// makes this reachable for more than one package per location.
|
|
1983
|
-
if (absolutePackagePath) {
|
|
1984
|
-
await clearMountTarget(absolutePackagePath);
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
try {
|
|
1989
|
-
// Clean up temporary download directory
|
|
1990
|
-
await fs.promises.rm(tempDownloadPath, {
|
|
1991
|
-
recursive: true,
|
|
1992
|
-
force: true,
|
|
1993
|
-
});
|
|
1994
|
-
} catch (error) {
|
|
1995
|
-
logger.warn(
|
|
1996
|
-
`Failed to clean up temporary download directory "${tempDownloadPath}"`,
|
|
1997
|
-
{
|
|
1998
|
-
error,
|
|
1999
|
-
},
|
|
2000
|
-
);
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
return { path: absoluteTargetPath, mountErrors };
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
private async downloadOrMountLocation(
|
|
2008
|
-
location: string,
|
|
2009
|
-
targetPath: string,
|
|
2010
|
-
environmentName: string,
|
|
2011
|
-
packageName: string,
|
|
2012
|
-
packageNames?: string[],
|
|
2013
|
-
) {
|
|
2014
|
-
const isCompressedFile = location.endsWith(".zip");
|
|
2015
|
-
// Handle GCS paths
|
|
2016
|
-
if (this.isGCSURL(location)) {
|
|
2017
|
-
try {
|
|
2018
|
-
logger.info(
|
|
2019
|
-
`Downloading GCS directory from "${location}" to "${targetPath}"`,
|
|
2020
|
-
);
|
|
2021
|
-
await this.downloadGcsDirectory(
|
|
2022
|
-
location,
|
|
2023
|
-
environmentName,
|
|
2024
|
-
targetPath,
|
|
2025
|
-
isCompressedFile,
|
|
2026
|
-
);
|
|
2027
|
-
return;
|
|
2028
|
-
} catch (error) {
|
|
2029
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
2030
|
-
logger.error(
|
|
2031
|
-
`Failed to download GCS directory "${location}"`,
|
|
2032
|
-
errorData,
|
|
2033
|
-
);
|
|
2034
|
-
throw new PackageNotFoundError(
|
|
2035
|
-
`Failed to download GCS directory: ${location}`,
|
|
2036
|
-
);
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
// Handle GitHub URLs
|
|
2041
|
-
if (this.isGitHubURL(location)) {
|
|
2042
|
-
try {
|
|
2043
|
-
logger.info(
|
|
2044
|
-
`Cloning GitHub repository from "${location}" to "${targetPath}"`,
|
|
2045
|
-
);
|
|
2046
|
-
await this.downloadGitHubDirectory(location, targetPath, {
|
|
2047
|
-
environmentName,
|
|
2048
|
-
packageNames,
|
|
2049
|
-
});
|
|
2050
|
-
return;
|
|
2051
|
-
} catch (error) {
|
|
2052
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
2053
|
-
logger.error(
|
|
2054
|
-
`Failed to clone GitHub repository "${location}"`,
|
|
2055
|
-
errorData,
|
|
2056
|
-
);
|
|
2057
|
-
throw new PackageNotFoundError(
|
|
2058
|
-
`Failed to clone GitHub repository: ${location}`,
|
|
2059
|
-
);
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
// Handle S3 paths
|
|
2064
|
-
if (this.isS3URL(location)) {
|
|
2065
|
-
try {
|
|
2066
|
-
logger.info(
|
|
2067
|
-
`Downloading S3 directory from "${location}" to "${targetPath}"`,
|
|
2068
|
-
);
|
|
2069
|
-
await this.downloadS3Directory(
|
|
2070
|
-
location,
|
|
2071
|
-
environmentName,
|
|
2072
|
-
targetPath,
|
|
2073
|
-
isCompressedFile,
|
|
2074
|
-
);
|
|
2075
|
-
return;
|
|
2076
|
-
} catch (error) {
|
|
2077
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
2078
|
-
logger.error(
|
|
2079
|
-
`Failed to download S3 directory "${location}"`,
|
|
2080
|
-
errorData,
|
|
2081
|
-
);
|
|
2082
|
-
throw new PackageNotFoundError(
|
|
2083
|
-
`Failed to download S3 directory: ${location}`,
|
|
2084
|
-
);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// Handle absolute and relative paths
|
|
2089
|
-
if (this.isLocalPath(location)) {
|
|
2090
|
-
const packagePath: string = this.resolveLocalPath(location);
|
|
2091
|
-
try {
|
|
2092
|
-
logger.info(
|
|
2093
|
-
`Mounting local directory at "${packagePath}" to "${targetPath}"`,
|
|
2094
|
-
);
|
|
2095
|
-
await this.mountLocalDirectory(
|
|
2096
|
-
packagePath,
|
|
2097
|
-
targetPath,
|
|
2098
|
-
environmentName,
|
|
2099
|
-
packageName,
|
|
2100
|
-
);
|
|
2101
|
-
return;
|
|
2102
|
-
} catch (error) {
|
|
2103
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
2104
|
-
logger.error(
|
|
2105
|
-
`Failed to mount local directory "${packagePath}"`,
|
|
2106
|
-
errorData,
|
|
2107
|
-
);
|
|
2108
|
-
throw new PackageNotFoundError(
|
|
2109
|
-
`Failed to mount local directory: ${packagePath}`,
|
|
2110
|
-
);
|
|
2111
|
-
}
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
// If we get here, the path format is not supported
|
|
2115
|
-
const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
|
|
2116
|
-
logger.error(errorMsg, { environmentName, location });
|
|
2117
|
-
throw new PackageNotFoundError(errorMsg);
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
public async mountLocalDirectory(
|
|
2121
|
-
environmentPath: string,
|
|
2122
|
-
absoluteTargetPath: string,
|
|
2123
|
-
environmentName: string,
|
|
2124
|
-
packageName: string,
|
|
2125
|
-
) {
|
|
2126
|
-
// `environmentPath` is the operator-supplied mount source and may
|
|
2127
|
-
// legitimately be a relative path that resolves outside the
|
|
2128
|
-
// server root; only the target is asserted.
|
|
2129
|
-
assertSafeEnvironmentPath(absoluteTargetPath);
|
|
2130
|
-
if (environmentPath.endsWith(".zip")) {
|
|
2131
|
-
environmentPath = await this.unzipEnvironment(environmentPath);
|
|
2132
|
-
}
|
|
2133
|
-
const environmentDirExists =
|
|
2134
|
-
(await fs.promises.stat(environmentPath))?.isDirectory() ?? false;
|
|
2135
|
-
if (environmentDirExists) {
|
|
2136
|
-
await fs.promises.rm(absoluteTargetPath, {
|
|
2137
|
-
recursive: true,
|
|
2138
|
-
force: true,
|
|
2139
|
-
});
|
|
2140
|
-
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
2141
|
-
await fs.promises.cp(environmentPath, absoluteTargetPath, {
|
|
2142
|
-
recursive: true,
|
|
2143
|
-
});
|
|
2144
|
-
} else {
|
|
2145
|
-
throw new PackageNotFoundError(
|
|
2146
|
-
`Package ${packageName} for environment ${environmentName} not found in "${environmentPath}"`,
|
|
2147
|
-
);
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
async downloadGcsDirectory(
|
|
2152
|
-
gcsPath: string,
|
|
2153
|
-
environmentName: string,
|
|
2154
|
-
absoluteDirPath: string,
|
|
2155
|
-
isCompressedFile: boolean,
|
|
2156
|
-
) {
|
|
2157
|
-
assertSafeEnvironmentPath(absoluteDirPath);
|
|
2158
|
-
const trimmedPath = gcsPath.slice(5);
|
|
2159
|
-
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
2160
|
-
const prefix = prefixParts.join("/");
|
|
2161
|
-
const [files] = await this.gcsClient.bucket(bucketName).getFiles({
|
|
2162
|
-
prefix,
|
|
2163
|
-
});
|
|
2164
|
-
if (files.length === 0) {
|
|
2165
|
-
throw new EnvironmentNotFoundError(
|
|
2166
|
-
`Environment ${environmentName} not found in ${gcsPath}`,
|
|
2167
|
-
);
|
|
2168
|
-
}
|
|
2169
|
-
if (!isCompressedFile) {
|
|
2170
|
-
await fs.promises.rm(absoluteDirPath, {
|
|
2171
|
-
recursive: true,
|
|
2172
|
-
force: true,
|
|
2173
|
-
});
|
|
2174
|
-
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
2175
|
-
} else {
|
|
2176
|
-
absoluteDirPath = `${absoluteDirPath}.zip`;
|
|
2177
|
-
}
|
|
2178
|
-
await Promise.all(
|
|
2179
|
-
files.map(async (file) => {
|
|
2180
|
-
// Strip leading `/` left over from prefix removal when the
|
|
2181
|
-
// GCS prefix lacked a trailing slash — otherwise
|
|
2182
|
-
// `safeJoinUnderRoot` treats it as absolute and rejects.
|
|
2183
|
-
const relativeFilePath = file.name
|
|
2184
|
-
.replace(prefix, "")
|
|
2185
|
-
.replace(/^\/+/, "");
|
|
2186
|
-
const absoluteFilePath = isCompressedFile
|
|
2187
|
-
? absoluteDirPath
|
|
2188
|
-
: safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
|
|
2189
|
-
if (file.name.endsWith("/")) {
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
await fs.promises.mkdir(path.dirname(absoluteFilePath), {
|
|
2193
|
-
recursive: true,
|
|
2194
|
-
});
|
|
2195
|
-
return fs.promises.writeFile(
|
|
2196
|
-
absoluteFilePath,
|
|
2197
|
-
await file.download(),
|
|
2198
|
-
);
|
|
2199
|
-
}),
|
|
2200
|
-
);
|
|
2201
|
-
if (isCompressedFile) {
|
|
2202
|
-
await this.unzipEnvironment(absoluteDirPath);
|
|
2203
|
-
}
|
|
2204
|
-
logger.info(`Downloaded GCS directory ${gcsPath} to ${absoluteDirPath}`);
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
async downloadS3Directory(
|
|
2208
|
-
s3Path: string,
|
|
2209
|
-
environmentName: string,
|
|
2210
|
-
absoluteDirPath: string,
|
|
2211
|
-
isCompressedFile: boolean = false,
|
|
2212
|
-
) {
|
|
2213
|
-
assertSafeEnvironmentPath(absoluteDirPath);
|
|
2214
|
-
const trimmedPath = s3Path.slice(5);
|
|
2215
|
-
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
2216
|
-
const prefix = prefixParts.join("/");
|
|
2217
|
-
|
|
2218
|
-
if (isCompressedFile) {
|
|
2219
|
-
// Download the single zip file
|
|
2220
|
-
const zipFilePath = `${absoluteDirPath}.zip`;
|
|
2221
|
-
await fs.promises.mkdir(path.dirname(zipFilePath), {
|
|
2222
|
-
recursive: true,
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
const command = new GetObjectCommand({
|
|
2226
|
-
Bucket: bucketName,
|
|
2227
|
-
Key: prefix,
|
|
2228
|
-
});
|
|
2229
|
-
const item = await this.s3Client.send(command);
|
|
2230
|
-
if (!item.Body) {
|
|
2231
|
-
throw new EnvironmentNotFoundError(
|
|
2232
|
-
`Environment ${environmentName} not found in ${s3Path}`,
|
|
2233
|
-
);
|
|
2234
|
-
}
|
|
2235
|
-
const file = fs.createWriteStream(zipFilePath);
|
|
2236
|
-
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
2237
|
-
await new Promise<void>((resolve, reject) => {
|
|
2238
|
-
file.on("error", reject);
|
|
2239
|
-
file.on("finish", resolve);
|
|
2240
|
-
});
|
|
2241
|
-
|
|
2242
|
-
// Extract the zip file
|
|
2243
|
-
await this.unzipEnvironment(zipFilePath);
|
|
2244
|
-
logger.info(`Downloaded S3 zip file ${s3Path} to ${absoluteDirPath}`);
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
// Original behavior: download directory contents
|
|
2249
|
-
const objects = await this.s3Client.listObjectsV2({
|
|
2250
|
-
Bucket: bucketName,
|
|
2251
|
-
Prefix: prefix,
|
|
2252
|
-
});
|
|
2253
|
-
await fs.promises.rm(absoluteDirPath, { recursive: true, force: true });
|
|
2254
|
-
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
2255
|
-
|
|
2256
|
-
if (!objects.Contents || objects.Contents.length === 0) {
|
|
2257
|
-
throw new EnvironmentNotFoundError(
|
|
2258
|
-
`Environment ${environmentName} not found in ${s3Path}`,
|
|
2259
|
-
);
|
|
2260
|
-
}
|
|
2261
|
-
await Promise.all(
|
|
2262
|
-
objects.Contents?.map(async (object) => {
|
|
2263
|
-
const key = object.Key;
|
|
2264
|
-
if (!key) {
|
|
2265
|
-
return;
|
|
2266
|
-
}
|
|
2267
|
-
// Strip leading `/` left over from prefix removal when the
|
|
2268
|
-
// S3 prefix lacked a trailing slash — otherwise
|
|
2269
|
-
// `safeJoinUnderRoot` treats it as absolute and rejects.
|
|
2270
|
-
const relativeFilePath = key
|
|
2271
|
-
.replace(prefix, "")
|
|
2272
|
-
.replace(/^\/+/, "");
|
|
2273
|
-
if (!relativeFilePath || relativeFilePath.endsWith("/")) {
|
|
2274
|
-
return;
|
|
2275
|
-
}
|
|
2276
|
-
const absoluteFilePath = safeJoinUnderRoot(
|
|
2277
|
-
absoluteDirPath,
|
|
2278
|
-
relativeFilePath,
|
|
2279
|
-
);
|
|
2280
|
-
await fs.promises.mkdir(path.dirname(absoluteFilePath), {
|
|
2281
|
-
recursive: true,
|
|
2282
|
-
});
|
|
2283
|
-
const command = new GetObjectCommand({
|
|
2284
|
-
Bucket: bucketName,
|
|
2285
|
-
Key: key,
|
|
2286
|
-
});
|
|
2287
|
-
const item = await this.s3Client.send(command);
|
|
2288
|
-
if (!item.Body) {
|
|
2289
|
-
return;
|
|
2290
|
-
}
|
|
2291
|
-
const file = fs.createWriteStream(absoluteFilePath);
|
|
2292
|
-
item.Body.transformToWebStream().pipeTo(Writable.toWeb(file));
|
|
2293
|
-
await new Promise<void>((resolve, reject) => {
|
|
2294
|
-
file.on("error", reject);
|
|
2295
|
-
file.on("finish", resolve);
|
|
2296
|
-
});
|
|
2297
|
-
}),
|
|
2298
|
-
);
|
|
2299
|
-
logger.info(`Downloaded S3 directory ${s3Path} to ${absoluteDirPath}`);
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
async downloadGitHubDirectory(
|
|
2303
|
-
githubUrl: string,
|
|
2304
|
-
absoluteDirPath: string,
|
|
2305
|
-
progressContext?: { environmentName?: string; packageNames?: string[] },
|
|
2306
|
-
) {
|
|
2307
|
-
assertSafeEnvironmentPath(absoluteDirPath);
|
|
2308
|
-
// First we'll clone the repo without the additional path
|
|
2309
|
-
// E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
|
|
2310
|
-
const githubInfo = parseGitHubUrl(githubUrl);
|
|
2311
|
-
if (!githubInfo) {
|
|
2312
|
-
throw new Error(`Invalid GitHub URL: ${githubUrl}`);
|
|
2313
|
-
}
|
|
2314
|
-
const { owner, repoName, packagePath } = githubInfo;
|
|
2315
|
-
// `packagePath` is captured with a leading `/`; strip it so the
|
|
2316
|
-
// value is a relative segment usable with `safeJoinUnderRoot`.
|
|
2317
|
-
const cleanPackagePath = (
|
|
2318
|
-
packagePath?.replace("/tree/main", "") || ""
|
|
2319
|
-
).replace(/^\/+/, "");
|
|
2320
|
-
|
|
2321
|
-
// We'll make sure whatever was in absoluteDirPath is removed,
|
|
2322
|
-
// so we have a nice a clean directory where we can clone the repo
|
|
2323
|
-
await fs.promises.rm(absoluteDirPath, {
|
|
2324
|
-
recursive: true,
|
|
2325
|
-
force: true,
|
|
2326
|
-
});
|
|
2327
|
-
await fs.promises.mkdir(absoluteDirPath, { recursive: true });
|
|
2328
|
-
const repoUrl = `https://github.com/${owner}/${repoName}`;
|
|
2329
|
-
const reporter = new CloneProgressReporter(
|
|
2330
|
-
cloneProgressLabel(`${owner}/${repoName}`, progressContext),
|
|
2331
|
-
);
|
|
2332
|
-
|
|
2333
|
-
// We'll clone the repo into absoluteDirPath
|
|
2334
|
-
await new Promise<void>((resolve, reject) => {
|
|
2335
|
-
simpleGit({
|
|
2336
|
-
progress: (event) => reporter.onProgress(event),
|
|
2337
|
-
}).clone(repoUrl, absoluteDirPath, GIT_CLONE_OPTIONS, (err) => {
|
|
2338
|
-
reporter.done();
|
|
2339
|
-
if (err) {
|
|
2340
|
-
err.message = stripGitProgressNoise(err.message);
|
|
2341
|
-
if (err.stack) {
|
|
2342
|
-
// V8 captures the message into the stack at construction,
|
|
2343
|
-
// so the spew survives there unless stripped too.
|
|
2344
|
-
err.stack = stripGitProgressNoise(err.stack);
|
|
2345
|
-
}
|
|
2346
|
-
const errorData = this.extractErrorDataFromError(err);
|
|
2347
|
-
logger.error(
|
|
2348
|
-
`Failed to clone GitHub repository "${repoUrl}"`,
|
|
2349
|
-
errorData,
|
|
2350
|
-
);
|
|
2351
|
-
reject(err);
|
|
2352
|
-
return;
|
|
2353
|
-
}
|
|
2354
|
-
resolve();
|
|
2355
|
-
});
|
|
2356
|
-
});
|
|
2357
|
-
|
|
2358
|
-
// If there's no specific package path, we're done (for grouped downloads)
|
|
2359
|
-
if (!cleanPackagePath) {
|
|
2360
|
-
logger.info(
|
|
2361
|
-
`Successfully cloned entire repository to: ${absoluteDirPath}`,
|
|
2362
|
-
);
|
|
2363
|
-
return;
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// For single package downloads, extract the specific subdirectory
|
|
2367
|
-
// After cloning, we'll replace all contents of absoluteDirPath with the contents of absoluteDirPath/cleanPackagePath
|
|
2368
|
-
// E.g. we're moving /var/publisher/asd123/imdb/publisher.json into /var/publisher/asd123/publisher.json
|
|
2369
|
-
|
|
2370
|
-
// Remove all contents of absoluteDirPath (/var/publisher/asd123)
|
|
2371
|
-
// except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
|
|
2372
|
-
const packageFullPath = safeJoinUnderRoot(
|
|
2373
|
-
absoluteDirPath,
|
|
2374
|
-
cleanPackagePath,
|
|
2375
|
-
);
|
|
2376
|
-
|
|
2377
|
-
// Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
|
|
2378
|
-
const packageExists = await fs.promises
|
|
2379
|
-
.access(packageFullPath)
|
|
2380
|
-
.then(() => true)
|
|
2381
|
-
.catch(() => false);
|
|
2382
|
-
|
|
2383
|
-
if (!packageExists) {
|
|
2384
|
-
throw new Error(
|
|
2385
|
-
`Package path "${cleanPackagePath}" does not exist in the cloned repository.`,
|
|
2386
|
-
);
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
// Remove everything in absoluteDirPath (/var/publisher/asd123)
|
|
2390
|
-
const dirContents = await fs.promises.readdir(absoluteDirPath);
|
|
2391
|
-
for (const entry of dirContents) {
|
|
2392
|
-
// Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
|
|
2393
|
-
if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
|
|
2394
|
-
await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
|
|
2395
|
-
recursive: true,
|
|
2396
|
-
force: true,
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
// Now, move the contents of packageFullPath (/var/publisher/asd123/imdb) up to absoluteDirPath (/var/publisher/asd123)
|
|
2402
|
-
const packageContents = await fs.promises.readdir(packageFullPath);
|
|
2403
|
-
for (const entry of packageContents) {
|
|
2404
|
-
await fs.promises.rename(
|
|
2405
|
-
safeJoinUnderRoot(packageFullPath, entry),
|
|
2406
|
-
safeJoinUnderRoot(absoluteDirPath, entry),
|
|
2407
|
-
);
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
// Remove the now-empty cleanPackagePath directory (/var/publisher/asd123/imdb)
|
|
2411
|
-
await fs.promises.rm(packageFullPath, { recursive: true, force: true });
|
|
2412
|
-
|
|
2413
|
-
// https://github.com/credibledata/malloy-samples/imdb/publisher.json -> ${absoluteDirPath}/publisher.json
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
private extractErrorDataFromError(error: unknown): {
|
|
2417
|
-
error: string;
|
|
2418
|
-
stack?: string;
|
|
2419
|
-
task?: unknown;
|
|
2420
|
-
} {
|
|
2421
|
-
const errorMessage =
|
|
2422
|
-
error instanceof Error ? error.message : String(error);
|
|
2423
|
-
const errorData: { error: string; stack?: string; task?: unknown } = {
|
|
2424
|
-
error: errorMessage,
|
|
2425
|
-
};
|
|
2426
|
-
if (error instanceof Error && logger.level === "debug") {
|
|
2427
|
-
errorData.stack = error.stack;
|
|
2428
|
-
}
|
|
2429
|
-
if (error && typeof error === "object" && "task" in error) {
|
|
2430
|
-
errorData.task = (error as { task?: unknown }).task;
|
|
2431
|
-
}
|
|
2432
|
-
return errorData;
|
|
2433
|
-
}
|
|
2434
|
-
}
|