@malloy-publisher/server 0.0.229 → 0.0.231
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 +18 -9
- package/dist/app/assets/{EnvironmentPage-QOoHiVeJ.js → EnvironmentPage-wa_EPkwK.js} +1 -1
- package/dist/app/assets/{HomePage-C71GOfVW.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-CrgCAwLe.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/{MainPage-BG5__FN3.js → MainPage-CuJLrPNI.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DE6PnrDR.js → MaterializationsPage-D_67x2ee.js} +1 -1
- package/dist/app/assets/{ModelPage-CcBjcbLm.js → ModelPage-D5JtAWqR.js} +1 -1
- package/dist/app/assets/{PackagePage-JTy3ztkB.js → PackagePage-BRwtqUSG.js} +1 -1
- package/dist/app/assets/{RouteError-Cymbp47a.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-C_nMnHr8.js → ThemeEditorPage-CTCeBneA.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CPQu-DQx.js → WorkbookPage-SN6f1RBm.js} +1 -1
- package/dist/app/assets/{core-Coi3caGs.es-CSOmajHS.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +1 -1
- package/dist/app/assets/{index-DlWCXghy.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/{index-DxArlgRD.js → index-C_tJstcx.js} +4 -4
- package/dist/app/assets/{index-CcuuST2X.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-CkmABCAw.js → index-DU4r7GdU.js} +435 -422
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +108 -7
- package/dist/server.mjs +576 -50
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.ts +40 -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/mcp/skills/skills_bundle.json +1 -1
- package/src/server.ts +7 -0
- 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 +71 -0
- 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/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +43 -9
- package/src/service/environment_store.spec.ts +197 -12
- package/src/service/environment_store.ts +88 -14
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/materialization_scheduler.spec.ts +29 -0
- package/src/service/materialization_service.spec.ts +119 -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 +12 -1
- package/src/storage/duckdb/MaterializationRepository.spec.ts +39 -2
- package/src/storage/duckdb/MaterializationRepository.ts +12 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/dist/app/assets/index-CM2qhQCI.js +0 -18
|
@@ -149,6 +149,17 @@ export class Environment {
|
|
|
149
149
|
* the only lasting record. Read by EnvironmentStore.getStatus.
|
|
150
150
|
*/
|
|
151
151
|
private failedPackages: Map<string, string> = new Map();
|
|
152
|
+
/**
|
|
153
|
+
* Why a configured package never reached the disk, keyed by package name.
|
|
154
|
+
*
|
|
155
|
+
* Separate from {@link failedPackages} because it is the more specific
|
|
156
|
+
* answer and has to win. A package whose location failed to mount is still
|
|
157
|
+
* seeded SERVING by addEnvironment, so its later lazy load fails on the
|
|
158
|
+
* manifest that was never copied and `listPackages` records that instead.
|
|
159
|
+
* Reporting "Package manifest ... does not exist." for what was really a
|
|
160
|
+
* typo'd `location` sends the reader hunting in the wrong place.
|
|
161
|
+
*/
|
|
162
|
+
private mountErrors: Map<string, string> = new Map();
|
|
152
163
|
private malloyConfig: EnvironmentMalloyConfig;
|
|
153
164
|
private connectionMutex = new Mutex();
|
|
154
165
|
private retiredConnectionGenerations =
|
|
@@ -472,10 +483,15 @@ export class Environment {
|
|
|
472
483
|
);
|
|
473
484
|
}
|
|
474
485
|
|
|
475
|
-
// Authorize backstop (the *who* axis, 403).
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
486
|
+
// Authorize backstop (the *who* axis, 403). NOT guarded by
|
|
487
|
+
// hasAuthorize(): that only inspects top-level modelDef.contents
|
|
488
|
+
// sources, so a gated source reached only via a cross-file/deep
|
|
489
|
+
// join is invisible to it and this backstop would silently never
|
|
490
|
+
// run for such a model — the same bypass assertAuthorizedForRunnable
|
|
491
|
+
// itself closes on the query path (see model.ts
|
|
492
|
+
// assertAuthorizedForAllSources). The own-source probe and joined-
|
|
493
|
+
// gate walk it runs are cheap no-ops for a genuinely ungated model.
|
|
494
|
+
if (queryMaterializer && gateModel) {
|
|
479
495
|
await gateModel.assertAuthorizedForRunnable(
|
|
480
496
|
queryMaterializer,
|
|
481
497
|
givens ?? {},
|
|
@@ -923,7 +939,7 @@ export class Environment {
|
|
|
923
939
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
924
940
|
// It loaded, so any earlier failure is stale. A package that failed at
|
|
925
941
|
// boot can be fixed on disk and reloaded without a restart.
|
|
926
|
-
this.
|
|
942
|
+
this.clearPackageLoadFailure(packageName);
|
|
927
943
|
logger.debug(`Successfully loaded package ${packageName}`);
|
|
928
944
|
|
|
929
945
|
return _package;
|
|
@@ -1008,7 +1024,7 @@ export class Environment {
|
|
|
1008
1024
|
// Same reasoning as the load and install paths: it is serving now, so an
|
|
1009
1025
|
// earlier boot failure is stale. Without this, a package fixed on disk
|
|
1010
1026
|
// and re-added keeps its loadError for the life of the process.
|
|
1011
|
-
this.
|
|
1027
|
+
this.clearPackageLoadFailure(packageName);
|
|
1012
1028
|
return this.packages.get(packageName);
|
|
1013
1029
|
}
|
|
1014
1030
|
|
|
@@ -1198,7 +1214,7 @@ export class Environment {
|
|
|
1198
1214
|
this.packages.set(packageName, newPackage);
|
|
1199
1215
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1200
1216
|
// Publishing a fixed package clears the boot failure it replaces.
|
|
1201
|
-
this.
|
|
1217
|
+
this.clearPackageLoadFailure(packageName);
|
|
1202
1218
|
|
|
1203
1219
|
if (oldPackage) {
|
|
1204
1220
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
@@ -1547,9 +1563,27 @@ export class Environment {
|
|
|
1547
1563
|
return this.packageStatuses.get(packageName);
|
|
1548
1564
|
}
|
|
1549
1565
|
|
|
1566
|
+
/**
|
|
1567
|
+
* Record why a configured package's location never mounted, so /status can
|
|
1568
|
+
* name the real cause instead of the missing-manifest fallout it produces.
|
|
1569
|
+
* Called by EnvironmentStore right after the environment is created.
|
|
1570
|
+
*/
|
|
1571
|
+
public setPackageMountError(packageName: string, message: string): void {
|
|
1572
|
+
this.mountErrors.set(packageName, message);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** Forget any recorded failure for a package, whatever its cause. */
|
|
1576
|
+
private clearPackageLoadFailure(packageName: string): void {
|
|
1577
|
+
this.failedPackages.delete(packageName);
|
|
1578
|
+
this.mountErrors.delete(packageName);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1550
1581
|
/** Packages configured for this environment that did not load, and why. */
|
|
1551
1582
|
public getFailedPackages(): ReadonlyMap<string, string> {
|
|
1552
|
-
return this.failedPackages;
|
|
1583
|
+
if (this.mountErrors.size === 0) return this.failedPackages;
|
|
1584
|
+
// Mount errors last, so the specific cause overwrites the generic
|
|
1585
|
+
// manifest error that the un-mounted package produces on its lazy load.
|
|
1586
|
+
return new Map([...this.failedPackages, ...this.mountErrors]);
|
|
1553
1587
|
}
|
|
1554
1588
|
|
|
1555
1589
|
public setPackageStatus(packageName: string, status: PackageStatus): void {
|
|
@@ -1574,7 +1608,7 @@ export class Environment {
|
|
|
1574
1608
|
// the controller still drops its config row. Leaving the entry would
|
|
1575
1609
|
// make getStatus report a loadError for a package that is no longer
|
|
1576
1610
|
// configured, which is the one thing that channel must not do.
|
|
1577
|
-
this.
|
|
1611
|
+
this.clearPackageLoadFailure(packageName);
|
|
1578
1612
|
|
|
1579
1613
|
const _package = this.packages.get(packageName);
|
|
1580
1614
|
if (!_package) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { promises as fsPromises } from "fs";
|
|
3
4
|
import * as path from "path";
|
|
4
5
|
import * as sinon from "sinon";
|
|
5
6
|
import { components } from "../api";
|
|
@@ -470,6 +471,169 @@ describe("EnvironmentStore Service", () => {
|
|
|
470
471
|
expect("loadErrors" in status).toBe(false);
|
|
471
472
|
});
|
|
472
473
|
|
|
474
|
+
it("keeps good packages serving when a sibling's location is bad", async () => {
|
|
475
|
+
// The headline case: one bad location in an environment must not take
|
|
476
|
+
// down its healthy siblings. Two good packages plus one whose location
|
|
477
|
+
// does not exist -> both good ones serve, the bad one is a per-package
|
|
478
|
+
// loadError, and the environment is intact.
|
|
479
|
+
const goodA = path.join(serverRootPath, "good-a");
|
|
480
|
+
const goodB = path.join(serverRootPath, "good-b");
|
|
481
|
+
for (const dir of [goodA, goodB]) {
|
|
482
|
+
mkdirSync(dir, { recursive: true });
|
|
483
|
+
writeFileSync(
|
|
484
|
+
path.join(dir, "publisher.json"),
|
|
485
|
+
JSON.stringify({ name: path.basename(dir) }),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
writeFileSync(
|
|
489
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
490
|
+
JSON.stringify({
|
|
491
|
+
environments: [
|
|
492
|
+
{
|
|
493
|
+
name: projectName,
|
|
494
|
+
packages: [
|
|
495
|
+
{ name: "good-a", location: goodA },
|
|
496
|
+
{ name: "bad", location: "/non/existent/path" },
|
|
497
|
+
{ name: "good-b", location: goodB },
|
|
498
|
+
],
|
|
499
|
+
connections: [],
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
506
|
+
await newEnvironmentStore.finishedInitialization;
|
|
507
|
+
|
|
508
|
+
const status = await newEnvironmentStore.getStatus();
|
|
509
|
+
expect(status.operationalState).toBe("serving");
|
|
510
|
+
expect(status.environments.map((e) => e.name)).toEqual([projectName]);
|
|
511
|
+
|
|
512
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
513
|
+
const packages = await environment.listPackages();
|
|
514
|
+
expect(packages.map((p) => p.name).sort()).toEqual(["good-a", "good-b"]);
|
|
515
|
+
|
|
516
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
517
|
+
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
518
|
+
expect(status.loadErrors?.[0]?.package).toBe("bad");
|
|
519
|
+
// The message has to name the location that is actually wrong. The
|
|
520
|
+
// un-mounted package also fails its lazy load on the manifest that was
|
|
521
|
+
// never copied, and reporting that instead points the reader at
|
|
522
|
+
// publisher_data/ for what is really a typo in the config.
|
|
523
|
+
expect(status.loadErrors?.[0]?.message).toContain("/non/existent/path");
|
|
524
|
+
expect(status.loadErrors?.[0]?.message).not.toContain("publisher_data");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("keeps sibling packages serving when one sharing their location fails to extract", async () => {
|
|
528
|
+
// Packages grouped under ONE location share a single download, then each
|
|
529
|
+
// is extracted separately. A failure in one extract must not strand the
|
|
530
|
+
// siblings queued behind it: the group-level catch used to wrap the whole
|
|
531
|
+
// loop, so with three packages from one repo a throw on the second meant
|
|
532
|
+
// the third was never copied even though nothing was wrong with it.
|
|
533
|
+
const shared = path.join(serverRootPath, "shared-src");
|
|
534
|
+
mkdirSync(shared, { recursive: true });
|
|
535
|
+
writeFileSync(
|
|
536
|
+
path.join(shared, "publisher.json"),
|
|
537
|
+
JSON.stringify({ name: "shared" }),
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
writeFileSync(
|
|
541
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
542
|
+
JSON.stringify({
|
|
543
|
+
environments: [
|
|
544
|
+
{
|
|
545
|
+
name: projectName,
|
|
546
|
+
packages: [
|
|
547
|
+
{ name: "first", location: shared },
|
|
548
|
+
{ name: "middle", location: shared },
|
|
549
|
+
{ name: "last", location: shared },
|
|
550
|
+
],
|
|
551
|
+
connections: [],
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// Fail exactly one package's copy. Everything reachable from config is
|
|
558
|
+
// shared across the group (same location, hence the same source path), so
|
|
559
|
+
// the copy is the only per-package step that can realistically fail on
|
|
560
|
+
// its own — the same shape as a GitHub subdirectory that isn't there.
|
|
561
|
+
const realCp = fsPromises.cp;
|
|
562
|
+
const cpStub = sinon.stub(fsPromises, "cp").callsFake((async (
|
|
563
|
+
src: Parameters<typeof realCp>[0],
|
|
564
|
+
dest: Parameters<typeof realCp>[1],
|
|
565
|
+
opts?: Parameters<typeof realCp>[2],
|
|
566
|
+
) => {
|
|
567
|
+
if (String(dest).endsWith(`${path.sep}middle`)) {
|
|
568
|
+
throw new Error("simulated extract failure for middle");
|
|
569
|
+
}
|
|
570
|
+
return realCp(src, dest, opts);
|
|
571
|
+
}) as typeof realCp);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
575
|
+
await newEnvironmentStore.finishedInitialization;
|
|
576
|
+
|
|
577
|
+
const environment =
|
|
578
|
+
await newEnvironmentStore.getEnvironment(projectName);
|
|
579
|
+
const packages = await environment.listPackages();
|
|
580
|
+
// "last" is the one that regressed: it is queued behind the failure.
|
|
581
|
+
expect(packages.map((p) => p.name).sort()).toEqual(["first", "last"]);
|
|
582
|
+
|
|
583
|
+
const status = await newEnvironmentStore.getStatus();
|
|
584
|
+
expect(status.operationalState).toBe("serving");
|
|
585
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
586
|
+
expect(status.loadErrors?.[0]?.package).toBe("middle");
|
|
587
|
+
expect(status.loadErrors?.[0]?.message).toContain(
|
|
588
|
+
"simulated extract failure",
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
cpStub.restore();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("does not delete the environment directory when a package name is rejected", async () => {
|
|
596
|
+
// A package name that fails the allowlist throws as the FIRST statement of
|
|
597
|
+
// the extract, so the per-package catch runs before any path for it
|
|
598
|
+
// exists. safeJoinUnderRoot deliberately allows a name that resolves to
|
|
599
|
+
// the root, so re-deriving the cleanup target from the name would hand it
|
|
600
|
+
// the whole environment directory and take the healthy siblings with it.
|
|
601
|
+
const good = path.join(serverRootPath, "good-src");
|
|
602
|
+
mkdirSync(good, { recursive: true });
|
|
603
|
+
writeFileSync(
|
|
604
|
+
path.join(good, "publisher.json"),
|
|
605
|
+
JSON.stringify({ name: "good" }),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
writeFileSync(
|
|
609
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
environments: [
|
|
612
|
+
{
|
|
613
|
+
name: projectName,
|
|
614
|
+
packages: [
|
|
615
|
+
{ name: "good", location: good },
|
|
616
|
+
{ name: ".", location: good },
|
|
617
|
+
],
|
|
618
|
+
connections: [],
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
625
|
+
await newEnvironmentStore.finishedInitialization;
|
|
626
|
+
|
|
627
|
+
// Boot pre-validates the names and skips the environment, so reach the
|
|
628
|
+
// lazy path, which passes the config's package names through unchecked.
|
|
629
|
+
await newEnvironmentStore.getEnvironment(projectName).catch(() => {});
|
|
630
|
+
|
|
631
|
+
const envDir = path.join(serverRootPath, "publisher_data", projectName);
|
|
632
|
+
// The environment directory and the healthy sibling must both survive.
|
|
633
|
+
expect(existsSync(envDir)).toBe(true);
|
|
634
|
+
expect(existsSync(path.join(envDir, "good"))).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
|
|
473
637
|
it("should not report an environment that is serving even if its database sync fails", async () => {
|
|
474
638
|
// An environment reaches this.environments before addEnvironmentToDatabase
|
|
475
639
|
// runs, so a throw from that tail is caught by the same handler that
|
|
@@ -824,8 +988,10 @@ describe("EnvironmentStore Service", () => {
|
|
|
824
988
|
{ timeout: 30000 },
|
|
825
989
|
);
|
|
826
990
|
|
|
827
|
-
it("
|
|
828
|
-
//
|
|
991
|
+
it("isolates a bad package location to that package, keeping the environment", async () => {
|
|
992
|
+
// A package whose location does not exist used to abort the whole
|
|
993
|
+
// environment. It now behaves like a bad manifest: the package is
|
|
994
|
+
// dropped and reported per-package, and the environment still loads.
|
|
829
995
|
const publisherConfigPath = path.join(
|
|
830
996
|
serverRootPath,
|
|
831
997
|
"publisher.config.json",
|
|
@@ -847,10 +1013,20 @@ describe("EnvironmentStore Service", () => {
|
|
|
847
1013
|
}),
|
|
848
1014
|
);
|
|
849
1015
|
|
|
850
|
-
|
|
851
|
-
await
|
|
852
|
-
|
|
853
|
-
|
|
1016
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1017
|
+
await newEnvironmentStore.finishedInitialization;
|
|
1018
|
+
|
|
1019
|
+
// The environment loaded rather than being skipped.
|
|
1020
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
1021
|
+
expect(environment.metadata.name).toBe(projectName);
|
|
1022
|
+
|
|
1023
|
+
// And the failure is reported at the package level, not the env level.
|
|
1024
|
+
const status = await newEnvironmentStore.getStatus();
|
|
1025
|
+
expect(status.operationalState).toBe("serving");
|
|
1026
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
1027
|
+
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
1028
|
+
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
1029
|
+
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
854
1030
|
});
|
|
855
1031
|
|
|
856
1032
|
it("should handle invalid publisher config", async () => {
|
|
@@ -1095,8 +1271,10 @@ describe("Project Service Error Recovery", () => {
|
|
|
1095
1271
|
});
|
|
1096
1272
|
|
|
1097
1273
|
describe("Project Loading Error Recovery", () => {
|
|
1098
|
-
it("
|
|
1099
|
-
//
|
|
1274
|
+
it("keeps the environment when a package directory is missing, reporting per-package", async () => {
|
|
1275
|
+
// Same isolation as above, for a relative-to-serverRoot missing dir:
|
|
1276
|
+
// the environment loads and the missing package is a per-package
|
|
1277
|
+
// loadError rather than an environment-level skip.
|
|
1100
1278
|
const publisherConfigPath = path.join(
|
|
1101
1279
|
serverRootPath,
|
|
1102
1280
|
"publisher.config.json",
|
|
@@ -1121,10 +1299,17 @@ describe("Project Service Error Recovery", () => {
|
|
|
1121
1299
|
}),
|
|
1122
1300
|
);
|
|
1123
1301
|
|
|
1124
|
-
|
|
1125
|
-
await
|
|
1126
|
-
|
|
1127
|
-
|
|
1302
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1303
|
+
await newEnvironmentStore.finishedInitialization;
|
|
1304
|
+
|
|
1305
|
+
const environment =
|
|
1306
|
+
await newEnvironmentStore.getEnvironment(projectName);
|
|
1307
|
+
expect(environment.metadata.name).toBe(projectName);
|
|
1308
|
+
|
|
1309
|
+
const status = await newEnvironmentStore.getStatus();
|
|
1310
|
+
expect(status.operationalState).toBe("serving");
|
|
1311
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
1312
|
+
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
1128
1313
|
});
|
|
1129
1314
|
|
|
1130
1315
|
it(
|
|
@@ -1013,13 +1013,16 @@ export class EnvironmentStore {
|
|
|
1013
1013
|
(environment?.packages && environment.packages.length > 0) ||
|
|
1014
1014
|
(environmentConfig?.packages && environmentConfig.packages.length > 0);
|
|
1015
1015
|
let absoluteEnvironmentPath: string;
|
|
1016
|
+
let mountErrors: ReadonlyMap<string, string> = new Map();
|
|
1016
1017
|
if (hasPackages) {
|
|
1017
1018
|
const packagesToProcess =
|
|
1018
1019
|
environment?.packages || environmentConfig?.packages || [];
|
|
1019
|
-
|
|
1020
|
+
const loaded = await this.loadEnvironmentIntoDisk(
|
|
1020
1021
|
environmentName,
|
|
1021
1022
|
packagesToProcess,
|
|
1022
1023
|
);
|
|
1024
|
+
absoluteEnvironmentPath = loaded.path;
|
|
1025
|
+
mountErrors = loaded.mountErrors;
|
|
1023
1026
|
if (absoluteEnvironmentPath.endsWith(".zip")) {
|
|
1024
1027
|
absoluteEnvironmentPath = await this.unzipEnvironment(
|
|
1025
1028
|
absoluteEnvironmentPath,
|
|
@@ -1038,6 +1041,12 @@ export class EnvironmentStore {
|
|
|
1038
1041
|
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
1039
1042
|
newEnvironment.metadata.location = absoluteEnvironmentPath;
|
|
1040
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
|
+
|
|
1041
1050
|
this.environments.set(environmentName, newEnvironment);
|
|
1042
1051
|
// It loaded, so any earlier failure for this name is stale. A boot
|
|
1043
1052
|
// failure can be followed by a successful POST or lazy load of the same
|
|
@@ -1263,6 +1272,38 @@ export class EnvironmentStore {
|
|
|
1263
1272
|
return location.startsWith("s3://");
|
|
1264
1273
|
}
|
|
1265
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
|
+
|
|
1266
1307
|
private async loadEnvironmentIntoDisk(
|
|
1267
1308
|
environmentName: string,
|
|
1268
1309
|
packages: ApiEnvironment["packages"],
|
|
@@ -1273,6 +1314,10 @@ export class EnvironmentStore {
|
|
|
1273
1314
|
PUBLISHER_DATA_DIR,
|
|
1274
1315
|
environmentName,
|
|
1275
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>();
|
|
1276
1321
|
|
|
1277
1322
|
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
1278
1323
|
|
|
@@ -1333,6 +1378,11 @@ export class EnvironmentStore {
|
|
|
1333
1378
|
);
|
|
1334
1379
|
await fs.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
1335
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;
|
|
1336
1386
|
try {
|
|
1337
1387
|
// Use the existing download method for all locations
|
|
1338
1388
|
await this.downloadOrMountLocation(
|
|
@@ -1341,11 +1391,28 @@ export class EnvironmentStore {
|
|
|
1341
1391
|
environmentName,
|
|
1342
1392
|
"shared",
|
|
1343
1393
|
);
|
|
1344
|
-
|
|
1345
|
-
|
|
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 {
|
|
1346
1413
|
const packageDir = _package.name;
|
|
1347
1414
|
assertSafePackageName(packageDir);
|
|
1348
|
-
|
|
1415
|
+
absolutePackagePath = safeJoinUnderRoot(
|
|
1349
1416
|
absoluteTargetPath,
|
|
1350
1417
|
packageDir,
|
|
1351
1418
|
);
|
|
@@ -1481,16 +1548,23 @@ export class EnvironmentStore {
|
|
|
1481
1548
|
`Copied entire download as package "${packageDir}"`,
|
|
1482
1549
|
);
|
|
1483
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
|
+
}
|
|
1484
1567
|
}
|
|
1485
|
-
} catch (error) {
|
|
1486
|
-
const errorData = this.extractErrorDataFromError(error);
|
|
1487
|
-
logger.error(
|
|
1488
|
-
`Failed to download or mount location "${groupedLocation}"`,
|
|
1489
|
-
errorData,
|
|
1490
|
-
);
|
|
1491
|
-
throw new PackageNotFoundError(
|
|
1492
|
-
`Failed to download or mount location: ${groupedLocation}`,
|
|
1493
|
-
);
|
|
1494
1568
|
}
|
|
1495
1569
|
try {
|
|
1496
1570
|
// Clean up temporary download directory
|
|
@@ -1508,7 +1582,7 @@ export class EnvironmentStore {
|
|
|
1508
1582
|
}
|
|
1509
1583
|
}
|
|
1510
1584
|
|
|
1511
|
-
return absoluteTargetPath;
|
|
1585
|
+
return { path: absoluteTargetPath, mountErrors };
|
|
1512
1586
|
}
|
|
1513
1587
|
|
|
1514
1588
|
private async downloadOrMountLocation(
|