@malloy-publisher/server 0.0.228 → 0.0.230
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 +102 -10
- package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
- package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
- package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
- package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
- package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
- package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
- package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
- package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
- package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/index-C_tJstcx.js +529 -0
- package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +109 -8
- package/dist/server.mjs +7504 -80
- package/package.json +15 -13
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.spec.ts +87 -1
- package/src/config.ts +142 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/ducklake_version.spec.ts +163 -0
- package/src/ducklake_version.ts +153 -0
- package/src/errors.ts +12 -0
- package/src/malloy_pin_prereqs.spec.ts +25 -0
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/skills/build_skills_bundle.ts +29 -9
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +31 -2
- package/src/query_param_utils.ts +11 -0
- package/src/server.ts +74 -9
- package/src/service/authorize.spec.ts +22 -0
- package/src/service/authorize.ts +267 -10
- package/src/service/authorize_integration.spec.ts +1068 -26
- package/src/service/connection.spec.ts +79 -7
- package/src/service/connection.ts +312 -25
- package/src/service/connection_config.spec.ts +21 -0
- package/src/service/connection_config.ts +15 -1
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +156 -17
- package/src/service/environment_store.spec.ts +569 -14
- package/src/service/environment_store.ts +237 -31
- package/src/service/environment_store_anchoring.spec.ts +107 -0
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/manifest_loader.spec.ts +3 -1
- package/src/service/manifest_loader.ts +9 -5
- package/src/service/materialization_scheduler.spec.ts +289 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +286 -5
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +67 -0
- package/src/service/model.ts +656 -31
- package/src/service/package.spec.ts +38 -0
- package/src/service/package.ts +102 -2
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/persistence_policy.spec.ts +31 -0
- package/src/service/quoting.spec.ts +57 -1
- package/src/service/quoting.ts +33 -0
- package/src/storage/DatabaseInterface.ts +15 -0
- package/src/storage/duckdb/DuckDBRepository.ts +17 -0
- package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
- package/src/storage/duckdb/MaterializationRepository.ts +68 -0
- package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
- package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
- package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
- package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
- package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
- package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
- package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
- package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
- package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
- package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
- package/dist/app/assets/index-DDMrjIT3.js +0 -18
- package/dist/app/assets/index-EqslXZ44.js +0 -527
|
@@ -4,12 +4,14 @@ import { Mutex } from "async-mutex";
|
|
|
4
4
|
import crypto from "crypto";
|
|
5
5
|
import extract from "extract-zip";
|
|
6
6
|
import * as fs from "fs";
|
|
7
|
+
import * as os from "os";
|
|
7
8
|
import * as path from "path";
|
|
8
9
|
import simpleGit from "simple-git";
|
|
9
10
|
import { Writable } from "stream";
|
|
10
11
|
import { components } from "../api";
|
|
11
12
|
import {
|
|
12
13
|
getProcessedPublisherConfig,
|
|
14
|
+
getPublisherConfigDir,
|
|
13
15
|
isPublisherConfigFrozen,
|
|
14
16
|
ProcessedEnvironment,
|
|
15
17
|
ProcessedPublisherConfig,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
} from "../errors";
|
|
28
30
|
import { getOperationalState, markNotReady, markReady } from "../health";
|
|
29
31
|
import { formatDuration, logger } from "../logger";
|
|
32
|
+
import { redactPgSecrets } from "../pg_helpers";
|
|
30
33
|
import {
|
|
31
34
|
assertSafeEnvironmentPath,
|
|
32
35
|
assertSafePackageName,
|
|
@@ -37,6 +40,9 @@ import { StorageConfig, StorageManager } from "../storage/StorageManager";
|
|
|
37
40
|
import { Environment, PackageStatus } from "./environment";
|
|
38
41
|
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
39
42
|
type ApiEnvironment = components["schemas"]["Environment"];
|
|
43
|
+
type LoadError = NonNullable<
|
|
44
|
+
components["schemas"]["ServerStatus"]["loadErrors"]
|
|
45
|
+
>[number];
|
|
40
46
|
|
|
41
47
|
const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
|
|
42
48
|
const AZURE_DATA_EXTENSIONS = [
|
|
@@ -115,9 +121,49 @@ async function clearMountTarget(targetPath: string): Promise<void> {
|
|
|
115
121
|
}
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Absolute on-disk path for a local package `location`.
|
|
126
|
+
*
|
|
127
|
+
* `~/` (POSIX form only) expands to the home directory. Anything still
|
|
128
|
+
* relative resolves against `anchorDir`, the directory holding the active
|
|
129
|
+
* config, so a config and the packages it points at can be moved or
|
|
130
|
+
* committed together. Absolute locations are returned untouched.
|
|
131
|
+
*/
|
|
132
|
+
export function resolvePackageLocation(
|
|
133
|
+
location: string,
|
|
134
|
+
anchorDir: string,
|
|
135
|
+
homeDir?: string,
|
|
136
|
+
): string {
|
|
137
|
+
let expanded = location;
|
|
138
|
+
if (location.startsWith("~/")) {
|
|
139
|
+
// Resolved lazily, inside the tilde branch only: os.homedir() can throw
|
|
140
|
+
// where HOME is unset and there is no passwd entry for the uid (e.g. a
|
|
141
|
+
// distroless container with runAsUser), and a `./sales` or absolute
|
|
142
|
+
// location must never fail on that.
|
|
143
|
+
const home = homeDir ?? os.homedir();
|
|
144
|
+
if (!home) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Cannot expand "~" in location "${location}": home directory is not set`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
expanded = path.join(home, location.slice(2));
|
|
150
|
+
}
|
|
151
|
+
return path.isAbsolute(expanded) ? expanded : path.join(anchorDir, expanded);
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
export class EnvironmentStore {
|
|
119
155
|
public serverRootPath: string;
|
|
120
156
|
private environments: Map<string, Environment> = new Map();
|
|
157
|
+
/**
|
|
158
|
+
* Environments that were configured but did not load, keyed by name.
|
|
159
|
+
*
|
|
160
|
+
* A load failure is not fatal: the environment is skipped and the server
|
|
161
|
+
* still reports `serving`. Nothing else remembers it, because the throw
|
|
162
|
+
* happens before the environment reaches `this.environments` or the
|
|
163
|
+
* database, so this is the only record that a configured environment is
|
|
164
|
+
* missing rather than never asked for. Surfaced on `getStatus`.
|
|
165
|
+
*/
|
|
166
|
+
private failedEnvironments = new Map<string, string>();
|
|
121
167
|
private environmentMutexes = new Map<string, Mutex>();
|
|
122
168
|
public publisherConfigIsFrozen: boolean;
|
|
123
169
|
public finishedInitialization: Promise<void>;
|
|
@@ -197,6 +243,15 @@ export class EnvironmentStore {
|
|
|
197
243
|
}
|
|
198
244
|
}
|
|
199
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Snapshot of the environments currently held in memory. Used by the
|
|
248
|
+
* standalone materialization scheduler to sweep loaded packages without
|
|
249
|
+
* touching the database or triggering loads.
|
|
250
|
+
*/
|
|
251
|
+
public getLoadedEnvironments(): Environment[] {
|
|
252
|
+
return [...this.environments.values()];
|
|
253
|
+
}
|
|
254
|
+
|
|
200
255
|
private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
|
|
201
256
|
try {
|
|
202
257
|
await this.addEnvironment(
|
|
@@ -209,11 +264,21 @@ export class EnvironmentStore {
|
|
|
209
264
|
true,
|
|
210
265
|
);
|
|
211
266
|
} catch (error) {
|
|
212
|
-
this.
|
|
267
|
+
this.recordEnvironmentInitializationFailure(environment.name, error);
|
|
213
268
|
}
|
|
214
269
|
}
|
|
215
270
|
|
|
216
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Log a skipped environment AND remember why, for getStatus to report.
|
|
273
|
+
*
|
|
274
|
+
* Both boot paths funnel through here: the config path
|
|
275
|
+
* ({@link addConfiguredEnvironment}) and the database-restore path taken by
|
|
276
|
+
* every restart against an existing publisher.db. Keeping the record here
|
|
277
|
+
* rather than in the callers is deliberate: recording it in only one of them
|
|
278
|
+
* would leave the other silently dropping failures, which is the bug this is
|
|
279
|
+
* meant to fix.
|
|
280
|
+
*/
|
|
281
|
+
private recordEnvironmentInitializationFailure(
|
|
217
282
|
environmentName: string | undefined,
|
|
218
283
|
error: unknown,
|
|
219
284
|
) {
|
|
@@ -222,6 +287,24 @@ export class EnvironmentStore {
|
|
|
222
287
|
`Error initializing environment${label}; skipping environment`,
|
|
223
288
|
this.extractErrorDataFromError(error),
|
|
224
289
|
);
|
|
290
|
+
// Only record an environment that is genuinely not there. An environment
|
|
291
|
+
// reaches `this.environments` before the rest of its setup (the database
|
|
292
|
+
// sync, its package listing) is done, and a throw from that tail lands
|
|
293
|
+
// here while the environment is live and serving. Reporting it would
|
|
294
|
+
// contradict `environments`, which still lists it, and a loadErrors entry
|
|
295
|
+
// with no `package` means the whole environment was skipped. It also could
|
|
296
|
+
// never be cleared: the clear below only runs when an environment is
|
|
297
|
+
// created, so a live one would carry the entry until restart.
|
|
298
|
+
if (environmentName && !this.environments.has(environmentName)) {
|
|
299
|
+
// Redacted because this is bound for an HTTP response body, not just a
|
|
300
|
+
// log line; see the same call in environment.ts.
|
|
301
|
+
this.failedEnvironments.set(
|
|
302
|
+
environmentName,
|
|
303
|
+
redactPgSecrets(
|
|
304
|
+
error instanceof Error ? error.message : String(error),
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
225
308
|
}
|
|
226
309
|
|
|
227
310
|
private async initialize() {
|
|
@@ -340,7 +423,7 @@ export class EnvironmentStore {
|
|
|
340
423
|
|
|
341
424
|
return environmentInstance.listPackages();
|
|
342
425
|
} catch (error) {
|
|
343
|
-
this.
|
|
426
|
+
this.recordEnvironmentInitializationFailure(
|
|
344
427
|
dbEnvironment.name,
|
|
345
428
|
error,
|
|
346
429
|
);
|
|
@@ -759,9 +842,16 @@ export class EnvironmentStore {
|
|
|
759
842
|
: baseState
|
|
760
843
|
) as components["schemas"]["ServerStatus"]["operationalState"];
|
|
761
844
|
|
|
762
|
-
const status
|
|
845
|
+
const status: {
|
|
846
|
+
timestamp: number;
|
|
847
|
+
environments: Array<components["schemas"]["Environment"]>;
|
|
848
|
+
initialized: boolean;
|
|
849
|
+
frozenConfig: boolean;
|
|
850
|
+
operationalState: components["schemas"]["ServerStatus"]["operationalState"];
|
|
851
|
+
loadErrors?: LoadError[];
|
|
852
|
+
} = {
|
|
763
853
|
timestamp: Date.now(),
|
|
764
|
-
environments: []
|
|
854
|
+
environments: [],
|
|
765
855
|
initialized: this.isInitialized,
|
|
766
856
|
frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
|
|
767
857
|
operationalState,
|
|
@@ -803,6 +893,34 @@ export class EnvironmentStore {
|
|
|
803
893
|
}
|
|
804
894
|
}),
|
|
805
895
|
);
|
|
896
|
+
|
|
897
|
+
// Collected AFTER listEnvironments, not before: serialize() awaits
|
|
898
|
+
// listPackages(), which is what loads an environment's packages and so
|
|
899
|
+
// records their failures. Reading the maps at the top of this method risks
|
|
900
|
+
// reading them before the failures they report have been recorded.
|
|
901
|
+
//
|
|
902
|
+
// A read-time overlay over live state, like "throttled" above, so there is
|
|
903
|
+
// no fourth stored status to keep in sync.
|
|
904
|
+
const loadErrors: LoadError[] = [];
|
|
905
|
+
for (const [environmentName, message] of this.failedEnvironments) {
|
|
906
|
+
loadErrors.push({ environment: environmentName, message });
|
|
907
|
+
}
|
|
908
|
+
for (const [environmentName, environment] of this.environments) {
|
|
909
|
+
for (const [packageName, message] of environment.getFailedPackages()) {
|
|
910
|
+
loadErrors.push({
|
|
911
|
+
environment: environmentName,
|
|
912
|
+
package: packageName,
|
|
913
|
+
message,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Left unset when nothing failed, so the key is absent from the response
|
|
918
|
+
// and a healthy server's status body is byte-for-byte what it was before
|
|
919
|
+
// this field existed.
|
|
920
|
+
if (loadErrors.length > 0) {
|
|
921
|
+
status.loadErrors = loadErrors;
|
|
922
|
+
}
|
|
923
|
+
|
|
806
924
|
return status;
|
|
807
925
|
}
|
|
808
926
|
|
|
@@ -895,13 +1013,16 @@ export class EnvironmentStore {
|
|
|
895
1013
|
(environment?.packages && environment.packages.length > 0) ||
|
|
896
1014
|
(environmentConfig?.packages && environmentConfig.packages.length > 0);
|
|
897
1015
|
let absoluteEnvironmentPath: string;
|
|
1016
|
+
let mountErrors: ReadonlyMap<string, string> = new Map();
|
|
898
1017
|
if (hasPackages) {
|
|
899
1018
|
const packagesToProcess =
|
|
900
1019
|
environment?.packages || environmentConfig?.packages || [];
|
|
901
|
-
|
|
1020
|
+
const loaded = await this.loadEnvironmentIntoDisk(
|
|
902
1021
|
environmentName,
|
|
903
1022
|
packagesToProcess,
|
|
904
1023
|
);
|
|
1024
|
+
absoluteEnvironmentPath = loaded.path;
|
|
1025
|
+
mountErrors = loaded.mountErrors;
|
|
905
1026
|
if (absoluteEnvironmentPath.endsWith(".zip")) {
|
|
906
1027
|
absoluteEnvironmentPath = await this.unzipEnvironment(
|
|
907
1028
|
absoluteEnvironmentPath,
|
|
@@ -920,7 +1041,18 @@ export class EnvironmentStore {
|
|
|
920
1041
|
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
921
1042
|
newEnvironment.metadata.location = absoluteEnvironmentPath;
|
|
922
1043
|
|
|
1044
|
+
// Attach before the SERVING seeding below: those packages are configured
|
|
1045
|
+
// but un-mounted, and this is the only record of why.
|
|
1046
|
+
for (const [packageName, message] of mountErrors) {
|
|
1047
|
+
newEnvironment.setPackageMountError(packageName, message);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
923
1050
|
this.environments.set(environmentName, newEnvironment);
|
|
1051
|
+
// It loaded, so any earlier failure for this name is stale. A boot
|
|
1052
|
+
// failure can be followed by a successful POST or lazy load of the same
|
|
1053
|
+
// environment, and a status that keeps reporting the old error would lie
|
|
1054
|
+
// in the other direction.
|
|
1055
|
+
this.failedEnvironments.delete(environmentName);
|
|
924
1056
|
|
|
925
1057
|
environment?.packages?.forEach((_package) => {
|
|
926
1058
|
if (_package.name) {
|
|
@@ -1110,6 +1242,21 @@ export class EnvironmentStore {
|
|
|
1110
1242
|
);
|
|
1111
1243
|
}
|
|
1112
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* Absolute on-disk path for a local package `location`, anchored at the
|
|
1247
|
+
* directory holding the active config. That covers locations POSTed at
|
|
1248
|
+
* runtime too, which no config declares. When there is no config worth
|
|
1249
|
+
* anchoring to, `getPublisherConfigDir` returns null (it owns the cases) and
|
|
1250
|
+
* the server root is the only meaningful base, which is also what this did
|
|
1251
|
+
* before the anchor moved.
|
|
1252
|
+
*/
|
|
1253
|
+
private resolveLocalPath(location: string): string {
|
|
1254
|
+
return resolvePackageLocation(
|
|
1255
|
+
location,
|
|
1256
|
+
getPublisherConfigDir(this.serverRootPath) ?? this.serverRootPath,
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1113
1260
|
private isGitHubURL(location: string) {
|
|
1114
1261
|
return (
|
|
1115
1262
|
location.startsWith("https://github.com/") ||
|
|
@@ -1125,6 +1272,38 @@ export class EnvironmentStore {
|
|
|
1125
1272
|
return location.startsWith("s3://");
|
|
1126
1273
|
}
|
|
1127
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* Isolate a location failure to the packages it actually affects instead of
|
|
1277
|
+
* aborting the whole environment. The packages that mounted still serve;
|
|
1278
|
+
* each one named here is left un-mounted, so its configured status entry
|
|
1279
|
+
* (set in addEnvironment) resolves to a per-package load failure that
|
|
1280
|
+
* getFailedPackages() -> /status loadErrors reports. A bad location now
|
|
1281
|
+
* behaves like a bad manifest: those packages dropped, siblings unaffected.
|
|
1282
|
+
*
|
|
1283
|
+
* The message is kept rather than letting the lazy load speak for it. An
|
|
1284
|
+
* un-mounted package fails later on the manifest that was never copied, and
|
|
1285
|
+
* "Package manifest ... does not exist." points at publisher_data/ instead
|
|
1286
|
+
* of the location that is actually wrong. Redacted because it reaches an
|
|
1287
|
+
* HTTP response body.
|
|
1288
|
+
*/
|
|
1289
|
+
private recordMountFailure(
|
|
1290
|
+
error: unknown,
|
|
1291
|
+
location: string,
|
|
1292
|
+
packageNames: string[],
|
|
1293
|
+
mountErrors: Map<string, string>,
|
|
1294
|
+
): void {
|
|
1295
|
+
logger.error(
|
|
1296
|
+
`Failed to download or mount location "${location}"`,
|
|
1297
|
+
this.extractErrorDataFromError(error),
|
|
1298
|
+
);
|
|
1299
|
+
const message = redactPgSecrets(
|
|
1300
|
+
error instanceof Error ? error.message : String(error),
|
|
1301
|
+
);
|
|
1302
|
+
for (const packageName of packageNames) {
|
|
1303
|
+
mountErrors.set(packageName, message);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1128
1307
|
private async loadEnvironmentIntoDisk(
|
|
1129
1308
|
environmentName: string,
|
|
1130
1309
|
packages: ApiEnvironment["packages"],
|
|
@@ -1135,6 +1314,10 @@ export class EnvironmentStore {
|
|
|
1135
1314
|
PUBLISHER_DATA_DIR,
|
|
1136
1315
|
environmentName,
|
|
1137
1316
|
);
|
|
1317
|
+
// Why each package's location failed, carried out to the Environment so
|
|
1318
|
+
// /status can report the real cause rather than the missing manifest it
|
|
1319
|
+
// leaves behind.
|
|
1320
|
+
const mountErrors = new Map<string, string>();
|
|
1138
1321
|
|
|
1139
1322
|
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
1140
1323
|
|
|
@@ -1195,6 +1378,11 @@ export class EnvironmentStore {
|
|
|
1195
1378
|
);
|
|
1196
1379
|
await fs.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
1197
1380
|
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
|
1381
|
+
// Two failure scopes, deliberately separate. The download is shared by
|
|
1382
|
+
// every package at this location, so losing it loses all of them. An
|
|
1383
|
+
// extract is per package, so one bad sibling must not strand the ones
|
|
1384
|
+
// after it in the loop.
|
|
1385
|
+
let downloaded = true;
|
|
1198
1386
|
try {
|
|
1199
1387
|
// Use the existing download method for all locations
|
|
1200
1388
|
await this.downloadOrMountLocation(
|
|
@@ -1203,11 +1391,28 @@ export class EnvironmentStore {
|
|
|
1203
1391
|
environmentName,
|
|
1204
1392
|
"shared",
|
|
1205
1393
|
);
|
|
1206
|
-
|
|
1207
|
-
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
downloaded = false;
|
|
1396
|
+
this.recordMountFailure(
|
|
1397
|
+
error,
|
|
1398
|
+
groupedLocation,
|
|
1399
|
+
packagesForLocation.map((p) => p.name),
|
|
1400
|
+
mountErrors,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
// Extract each package from the downloaded content
|
|
1404
|
+
for (const _package of downloaded ? packagesForLocation : []) {
|
|
1405
|
+
// Declared out here so the catch can clean up the exact path this
|
|
1406
|
+
// iteration created. Never re-derive it from the package name
|
|
1407
|
+
// there: the name may be why we are in the catch at all, and
|
|
1408
|
+
// safeJoinUnderRoot permits a name that resolves to the root, so a
|
|
1409
|
+
// package called "." would hand the cleanup the whole environment
|
|
1410
|
+
// directory. Undefined means nothing was created yet.
|
|
1411
|
+
let absolutePackagePath: string | undefined;
|
|
1412
|
+
try {
|
|
1208
1413
|
const packageDir = _package.name;
|
|
1209
1414
|
assertSafePackageName(packageDir);
|
|
1210
|
-
|
|
1415
|
+
absolutePackagePath = safeJoinUnderRoot(
|
|
1211
1416
|
absoluteTargetPath,
|
|
1212
1417
|
packageDir,
|
|
1213
1418
|
);
|
|
@@ -1236,15 +1441,11 @@ export class EnvironmentStore {
|
|
|
1236
1441
|
} else {
|
|
1237
1442
|
// For non-GitHub locations, use package name
|
|
1238
1443
|
if (this.isLocalPath(_package.location)) {
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
// branch is unreachable.
|
|
1245
|
-
sourcePath = path.isAbsolute(_package.location)
|
|
1246
|
-
? _package.location
|
|
1247
|
-
: path.join(this.serverRootPath, _package.location);
|
|
1444
|
+
// Same resolution rule as `downloadOrMountLocation`.
|
|
1445
|
+
// Without this step the existing-source check below
|
|
1446
|
+
// falls through for any relative location, and the
|
|
1447
|
+
// in-place mount branch is unreachable.
|
|
1448
|
+
sourcePath = this.resolveLocalPath(_package.location);
|
|
1248
1449
|
} else {
|
|
1249
1450
|
sourcePath = safeJoinUnderRoot(
|
|
1250
1451
|
tempDownloadPath,
|
|
@@ -1347,16 +1548,23 @@ export class EnvironmentStore {
|
|
|
1347
1548
|
`Copied entire download as package "${packageDir}"`,
|
|
1348
1549
|
);
|
|
1349
1550
|
}
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
this.recordMountFailure(
|
|
1553
|
+
error,
|
|
1554
|
+
groupedLocation,
|
|
1555
|
+
[_package.name],
|
|
1556
|
+
mountErrors,
|
|
1557
|
+
);
|
|
1558
|
+
// Leave nothing behind. A copy that threw part way through can
|
|
1559
|
+
// leave a tree holding the manifest and models but not the data,
|
|
1560
|
+
// which loads cleanly and therefore clears the very error
|
|
1561
|
+
// recorded above, so the package serves incomplete content and
|
|
1562
|
+
// /status says nothing. Continuing past the failure is what
|
|
1563
|
+
// makes this reachable for more than one package per location.
|
|
1564
|
+
if (absolutePackagePath) {
|
|
1565
|
+
await clearMountTarget(absolutePackagePath);
|
|
1566
|
+
}
|
|
1350
1567
|
}
|
|
1351
|
-
} catch (error) {
|
|
1352
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
1353
|
-
logger.error(
|
|
1354
|
-
`Failed to download or mount location "${groupedLocation}"`,
|
|
1355
|
-
errorData,
|
|
1356
|
-
);
|
|
1357
|
-
throw new PackageNotFoundError(
|
|
1358
|
-
`Failed to download or mount location: ${groupedLocation}`,
|
|
1359
|
-
);
|
|
1360
1568
|
}
|
|
1361
1569
|
try {
|
|
1362
1570
|
// Clean up temporary download directory
|
|
@@ -1374,7 +1582,7 @@ export class EnvironmentStore {
|
|
|
1374
1582
|
}
|
|
1375
1583
|
}
|
|
1376
1584
|
|
|
1377
|
-
return absoluteTargetPath;
|
|
1585
|
+
return { path: absoluteTargetPath, mountErrors };
|
|
1378
1586
|
}
|
|
1379
1587
|
|
|
1380
1588
|
private async downloadOrMountLocation(
|
|
@@ -1456,9 +1664,7 @@ export class EnvironmentStore {
|
|
|
1456
1664
|
|
|
1457
1665
|
// Handle absolute and relative paths
|
|
1458
1666
|
if (this.isLocalPath(location)) {
|
|
1459
|
-
const packagePath: string =
|
|
1460
|
-
? location
|
|
1461
|
-
: path.join(this.serverRootPath, location);
|
|
1667
|
+
const packagePath: string = this.resolveLocalPath(location);
|
|
1462
1668
|
try {
|
|
1463
1669
|
logger.info(
|
|
1464
1670
|
`Mounting local directory at "${packagePath}" to "${targetPath}"`,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { PUBLISHER_CONFIG_NAME, TEMP_DIR_PATH } from "../constants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A relative package `location` must anchor at the directory holding the config
|
|
8
|
+
* that declared it, not at the server root. Those are the same path in the
|
|
9
|
+
* common case (`<serverRoot>/publisher.config.json`), so a spec that lets them
|
|
10
|
+
* coincide cannot tell the two rules apart.
|
|
11
|
+
*
|
|
12
|
+
* Here they are deliberately different directories, driven through the real
|
|
13
|
+
* `PUBLISHER_CONFIG_PATH` that `--config` sets, so this exercises the actual
|
|
14
|
+
* resolution rather than a stub of it.
|
|
15
|
+
*/
|
|
16
|
+
const serverRootPath = path.join(TEMP_DIR_PATH, "anchoring-server-root");
|
|
17
|
+
const configDirPath = path.join(TEMP_DIR_PATH, "anchoring-config-dir");
|
|
18
|
+
|
|
19
|
+
mock.module("../storage/StorageManager", () => ({
|
|
20
|
+
StorageManager: class MockStorageManager {
|
|
21
|
+
async initialize(): Promise<void> {}
|
|
22
|
+
getRepository() {
|
|
23
|
+
return {
|
|
24
|
+
listEnvironments: async () => [],
|
|
25
|
+
getEnvironmentByName: async () => null,
|
|
26
|
+
createEnvironment: async (data: Record<string, unknown>) => ({
|
|
27
|
+
id: "env-id",
|
|
28
|
+
name: data.name,
|
|
29
|
+
path: data.path,
|
|
30
|
+
}),
|
|
31
|
+
listPackages: async () => [],
|
|
32
|
+
getPackageByName: async () => null,
|
|
33
|
+
createPackage: async (data: Record<string, unknown>) => ({
|
|
34
|
+
id: "pkg-id",
|
|
35
|
+
name: data.name,
|
|
36
|
+
}),
|
|
37
|
+
listConnections: async () => [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
StorageConfig: {} as Record<string, unknown>,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const { EnvironmentStore } = await import("./environment_store");
|
|
45
|
+
|
|
46
|
+
describe("relative package locations anchor at the config directory", () => {
|
|
47
|
+
const savedConfigPath = process.env.PUBLISHER_CONFIG_PATH;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
for (const dir of [serverRootPath, configDirPath]) {
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
const configPath = path.join(configDirPath, PUBLISHER_CONFIG_NAME);
|
|
55
|
+
writeFileSync(configPath, JSON.stringify({ environments: [] }));
|
|
56
|
+
process.env.PUBLISHER_CONFIG_PATH = configPath;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
if (savedConfigPath === undefined) {
|
|
61
|
+
delete process.env.PUBLISHER_CONFIG_PATH;
|
|
62
|
+
} else {
|
|
63
|
+
process.env.PUBLISHER_CONFIG_PATH = savedConfigPath;
|
|
64
|
+
}
|
|
65
|
+
for (const dir of [serverRootPath, configDirPath]) {
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("mounts a package that sits next to the config, not next to the server root", async () => {
|
|
71
|
+
// A `sales` directory exists in BOTH candidate anchors, with different
|
|
72
|
+
// contents. If resolution silently reverted to server-root anchoring it
|
|
73
|
+
// would still mount successfully — off the decoy — so the existence
|
|
74
|
+
// check alone could not catch the regression; the content check does.
|
|
75
|
+
const packageSource = path.join(configDirPath, "sales");
|
|
76
|
+
mkdirSync(packageSource, { recursive: true });
|
|
77
|
+
writeFileSync(
|
|
78
|
+
path.join(packageSource, "publisher.json"),
|
|
79
|
+
JSON.stringify({ name: "sales" }),
|
|
80
|
+
);
|
|
81
|
+
const decoySource = path.join(serverRootPath, "sales");
|
|
82
|
+
mkdirSync(decoySource, { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
path.join(decoySource, "publisher.json"),
|
|
85
|
+
JSON.stringify({ name: "decoy-from-server-root" }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const store = new EnvironmentStore(serverRootPath);
|
|
89
|
+
await store.addEnvironment({
|
|
90
|
+
name: "probe",
|
|
91
|
+
packages: [{ name: "sales", location: "./sales" }],
|
|
92
|
+
connections: [],
|
|
93
|
+
} as never);
|
|
94
|
+
|
|
95
|
+
const mountedManifest = path.join(
|
|
96
|
+
serverRootPath,
|
|
97
|
+
"publisher_data",
|
|
98
|
+
"probe",
|
|
99
|
+
"sales",
|
|
100
|
+
"publisher.json",
|
|
101
|
+
);
|
|
102
|
+
expect(existsSync(mountedManifest)).toBe(true);
|
|
103
|
+
expect(JSON.parse(readFileSync(mountedManifest, "utf-8")).name).toBe(
|
|
104
|
+
"sales",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|