@malloy-publisher/server 0.0.199 → 0.0.201
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 +110 -118
- package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-KoP4wt8H.js} +1 -1
- package/dist/app/assets/HomePage-HbPwKL84.js +1 -0
- package/dist/app/assets/MainPage-DfK4zDYO.js +2 -0
- package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-CUgSwGXg.js} +1 -1
- package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-CUDQNL5k.js} +1 -1
- package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-sgmtBdg8.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-tnWmLcrW.js} +1 -1
- package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-B3IQNPBD.es-foBNuT8L.js} +10 -10
- package/dist/app/assets/{index-D1pdwrUW.js → index-B5We8x8r.js} +1 -1
- package/dist/app/assets/{index-BUp81Qdm.js → index-KIvi9k3F.js} +1 -1
- package/dist/app/assets/index-PNYovl3E.js +452 -0
- package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-BXcsl2XW.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +1556 -1018
- package/package.json +1 -1
- package/publisher.config.json +4 -0
- package/src/config.spec.ts +246 -0
- package/src/config.ts +121 -1
- package/src/constants.ts +84 -1
- package/src/controller/connection.controller.spec.ts +803 -0
- package/src/controller/connection.controller.ts +207 -20
- package/src/controller/model.controller.ts +16 -5
- package/src/controller/query.controller.ts +20 -7
- package/src/controller/watch-mode.controller.ts +11 -2
- package/src/errors.spec.ts +44 -0
- package/src/errors.ts +34 -0
- package/src/filter_deprecation.spec.ts +64 -0
- package/src/filter_deprecation.ts +42 -0
- package/src/heap_check.spec.ts +144 -0
- package/src/heap_check.ts +144 -0
- package/src/mcp/handler_utils.ts +14 -0
- package/src/mcp/tools/execute_query_tool.ts +44 -14
- package/src/oom_guards.integration.spec.ts +261 -0
- package/src/path_safety.ts +9 -3
- package/src/query_cap_metrics.spec.ts +89 -0
- package/src/query_cap_metrics.ts +115 -0
- package/src/query_concurrency.spec.ts +247 -0
- package/src/query_concurrency.ts +236 -0
- package/src/query_timeout.spec.ts +224 -0
- package/src/query_timeout.ts +178 -0
- package/src/server-old.ts +20 -0
- package/src/server.ts +57 -72
- package/src/service/connection.spec.ts +244 -0
- package/src/service/connection.ts +14 -4
- package/src/service/environment.ts +124 -4
- package/src/service/environment_admission.spec.ts +165 -1
- package/src/service/environment_store.spec.ts +103 -0
- package/src/service/environment_store.ts +74 -23
- package/src/service/filter_integration.spec.ts +69 -0
- package/src/service/model.spec.ts +193 -3
- package/src/service/model.ts +95 -14
- package/src/service/model_limits.spec.ts +181 -0
- package/src/service/model_limits.ts +110 -0
- package/src/service/package.spec.ts +2 -6
- package/src/service/package.ts +6 -1
- package/src/service/path_injection.spec.ts +39 -0
- package/src/stream_helpers.spec.ts +280 -0
- package/src/stream_helpers.ts +162 -0
- package/src/test_helpers/metrics_harness.ts +126 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
- package/dist/app/assets/MainPage-DsVt5QGM.js +0 -2
- package/dist/app/assets/index-Dv5bF4Ii.js +0 -451
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GivenValue, LogMessage } from "@malloydata/malloy";
|
|
2
2
|
import { MalloyError, Runtime } from "@malloydata/malloy";
|
|
3
|
+
import { metrics } from "@opentelemetry/api";
|
|
3
4
|
import { Mutex } from "async-mutex";
|
|
4
5
|
import crypto from "crypto";
|
|
5
6
|
import * as fs from "fs";
|
|
@@ -69,6 +70,49 @@ type RetiredConnectionGeneration = {
|
|
|
69
70
|
|
|
70
71
|
const RETIRED_CONNECTION_DRAIN_MS = 30_000;
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Module-scoped admission-rejection counters. Lazy-initialized so
|
|
75
|
+
* the OTel JS `ProxyMeter` cannot strand them on a NoOp instrument
|
|
76
|
+
* created before the SDK MeterProvider was registered (a real risk
|
|
77
|
+
* in unit tests; see comment in `query_timeout.ts`). Environment
|
|
78
|
+
* name is attached as a label so dashboards can identify hot
|
|
79
|
+
* environments without grepping logs.
|
|
80
|
+
*/
|
|
81
|
+
import { type Counter } from "@opentelemetry/api";
|
|
82
|
+
let queryAdmissionRejectionsCounter: Counter | null = null;
|
|
83
|
+
let packageAdmissionRejectionsCounter: Counter | null = null;
|
|
84
|
+
function getQueryAdmissionRejectionsCounter(): Counter {
|
|
85
|
+
if (queryAdmissionRejectionsCounter) return queryAdmissionRejectionsCounter;
|
|
86
|
+
queryAdmissionRejectionsCounter = metrics
|
|
87
|
+
.getMeter("publisher")
|
|
88
|
+
.createCounter("publisher_query_admission_rejections_total", {
|
|
89
|
+
description:
|
|
90
|
+
"Queries rejected with 503 because Environment.assertCanAdmitQuery() observed memory back-pressure",
|
|
91
|
+
});
|
|
92
|
+
return queryAdmissionRejectionsCounter;
|
|
93
|
+
}
|
|
94
|
+
function getPackageAdmissionRejectionsCounter(): Counter {
|
|
95
|
+
if (packageAdmissionRejectionsCounter) {
|
|
96
|
+
return packageAdmissionRejectionsCounter;
|
|
97
|
+
}
|
|
98
|
+
packageAdmissionRejectionsCounter = metrics
|
|
99
|
+
.getMeter("publisher")
|
|
100
|
+
.createCounter("publisher_package_admission_rejections_total", {
|
|
101
|
+
description:
|
|
102
|
+
"Package loads rejected with 503 because Environment.assertCanAdmitNewPackage() observed memory back-pressure",
|
|
103
|
+
});
|
|
104
|
+
return packageAdmissionRejectionsCounter;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Visible for tests; production code never calls this. Resets the
|
|
109
|
+
* lazy caches so a fresh MeterProvider can capture future writes.
|
|
110
|
+
*/
|
|
111
|
+
export function resetAdmissionTelemetryForTesting(): void {
|
|
112
|
+
queryAdmissionRejectionsCounter = null;
|
|
113
|
+
packageAdmissionRejectionsCounter = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
export class Environment {
|
|
73
117
|
private packages: Map<string, Package> = new Map();
|
|
74
118
|
// Lock ordering: connectionMutex (environment) MUST be acquired before any
|
|
@@ -176,6 +220,7 @@ export class Environment {
|
|
|
176
220
|
environmentPath: string,
|
|
177
221
|
connections: ApiConnection[],
|
|
178
222
|
): Promise<Environment> {
|
|
223
|
+
assertSafeEnvironmentPath(environmentPath);
|
|
179
224
|
if (!(await fs.promises.stat(environmentPath))?.isDirectory()) {
|
|
180
225
|
throw new EnvironmentNotFoundError(
|
|
181
226
|
`Environment path ${environmentPath} not found`,
|
|
@@ -191,7 +236,10 @@ export class Environment {
|
|
|
191
236
|
logger.info(
|
|
192
237
|
`Loaded ${malloyConfig.apiConnections.length} connections for environment ${environmentName}`,
|
|
193
238
|
{
|
|
194
|
-
|
|
239
|
+
connections: malloyConfig.apiConnections.map((c) => ({
|
|
240
|
+
name: c.name,
|
|
241
|
+
type: c.type,
|
|
242
|
+
})),
|
|
195
243
|
},
|
|
196
244
|
);
|
|
197
245
|
|
|
@@ -218,7 +266,7 @@ export class Environment {
|
|
|
218
266
|
try {
|
|
219
267
|
readme = (
|
|
220
268
|
await fs.promises.readFile(
|
|
221
|
-
|
|
269
|
+
safeJoinUnderRoot(this.environmentPath, README_NAME),
|
|
222
270
|
)
|
|
223
271
|
).toString();
|
|
224
272
|
} catch {
|
|
@@ -579,8 +627,43 @@ export class Environment {
|
|
|
579
627
|
): void {
|
|
580
628
|
if (allowAdmission) return;
|
|
581
629
|
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
630
|
+
// Increment *before* throwing so the metric ticks even on
|
|
631
|
+
// the not-uncommon "caught and swallowed" path. The label
|
|
632
|
+
// shape mirrors `assertCanAdmitQuery` so a dashboard panel
|
|
633
|
+
// can sum both rejection kinds by environment.
|
|
634
|
+
getPackageAdmissionRejectionsCounter().add(1, {
|
|
635
|
+
environment: this.environmentName,
|
|
636
|
+
reason,
|
|
637
|
+
});
|
|
582
638
|
throw new ServiceUnavailableError(
|
|
583
|
-
`Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the
|
|
639
|
+
`Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Reject incoming queries with HTTP 503 when the memory governor
|
|
645
|
+
* has tripped its high-water mark. Used by every query controller
|
|
646
|
+
* (connection SQL, model query, notebook cell, MCP `execute_query`)
|
|
647
|
+
* to shed load before the query runs — complementing
|
|
648
|
+
* {@link assertCanAdmitNewPackage}, which only fires on cache-miss
|
|
649
|
+
* package loads and so leaves already-loaded packages fully
|
|
650
|
+
* queryable under pressure. With this in place, "back-pressured"
|
|
651
|
+
* means "no new work of any kind" until the governor's low-water
|
|
652
|
+
* mark is crossed.
|
|
653
|
+
*
|
|
654
|
+
* Cheap O(1) boolean read; no allocation when happy.
|
|
655
|
+
*/
|
|
656
|
+
public assertCanAdmitQuery(): void {
|
|
657
|
+
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
658
|
+
// Tick first so the counter reflects every rejection even
|
|
659
|
+
// when the controller's catch block swallows the error (e.g.
|
|
660
|
+
// an MCP tool surfaces it as a content payload rather than
|
|
661
|
+
// letting it bubble to the HTTP error mapper).
|
|
662
|
+
getQueryAdmissionRejectionsCounter().add(1, {
|
|
663
|
+
environment: this.environmentName,
|
|
664
|
+
});
|
|
665
|
+
throw new ServiceUnavailableError(
|
|
666
|
+
`Publisher is under memory pressure and cannot accept new queries (environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
|
|
584
667
|
);
|
|
585
668
|
}
|
|
586
669
|
|
|
@@ -696,7 +779,6 @@ export class Environment {
|
|
|
696
779
|
`Adding package ${packageName} to environment ${this.environmentName}`,
|
|
697
780
|
{
|
|
698
781
|
packagePath,
|
|
699
|
-
malloyConfig: this.malloyConfig.malloyConfig,
|
|
700
782
|
},
|
|
701
783
|
);
|
|
702
784
|
|
|
@@ -762,6 +844,12 @@ export class Environment {
|
|
|
762
844
|
const stagingPath = this.allocateStagingPath(packageName);
|
|
763
845
|
await fs.promises.mkdir(path.dirname(stagingPath), { recursive: true });
|
|
764
846
|
|
|
847
|
+
logger.debug("install.phase1.download.started", {
|
|
848
|
+
environmentName: this.environmentName,
|
|
849
|
+
packageName,
|
|
850
|
+
stagingPath,
|
|
851
|
+
});
|
|
852
|
+
const downloadStartedAt = performance.now();
|
|
765
853
|
try {
|
|
766
854
|
await downloader(stagingPath);
|
|
767
855
|
} catch (err) {
|
|
@@ -770,8 +858,17 @@ export class Environment {
|
|
|
770
858
|
.catch(() => {});
|
|
771
859
|
throw err;
|
|
772
860
|
}
|
|
861
|
+
logger.debug("install.phase1.download.completed", {
|
|
862
|
+
environmentName: this.environmentName,
|
|
863
|
+
packageName,
|
|
864
|
+
durationMs: performance.now() - downloadStartedAt,
|
|
865
|
+
});
|
|
773
866
|
|
|
774
867
|
return this.withPackageLock(packageName, async () => {
|
|
868
|
+
logger.debug("install.phase2.swap.started", {
|
|
869
|
+
environmentName: this.environmentName,
|
|
870
|
+
packageName,
|
|
871
|
+
});
|
|
775
872
|
const canonicalPath = safeJoinUnderRoot(
|
|
776
873
|
this.environmentPath,
|
|
777
874
|
packageName,
|
|
@@ -790,6 +887,11 @@ export class Environment {
|
|
|
790
887
|
recursive: true,
|
|
791
888
|
});
|
|
792
889
|
await fs.promises.rename(canonicalPath, retiredPath);
|
|
890
|
+
logger.debug("install.phase2.retired_old", {
|
|
891
|
+
environmentName: this.environmentName,
|
|
892
|
+
packageName,
|
|
893
|
+
retiredPath,
|
|
894
|
+
});
|
|
793
895
|
}
|
|
794
896
|
|
|
795
897
|
let newPackage: Package;
|
|
@@ -803,6 +905,11 @@ export class Environment {
|
|
|
803
905
|
canonicalPath,
|
|
804
906
|
() => this.malloyConfig.malloyConfig,
|
|
805
907
|
);
|
|
908
|
+
logger.debug("install.phase2.committed", {
|
|
909
|
+
environmentName: this.environmentName,
|
|
910
|
+
packageName,
|
|
911
|
+
canonicalPath,
|
|
912
|
+
});
|
|
806
913
|
} catch (err) {
|
|
807
914
|
// Rollback: clobber whatever (partial) content sits at canonical
|
|
808
915
|
// — Package.create's own failure-cleanup may have already rm'd
|
|
@@ -814,9 +921,11 @@ export class Environment {
|
|
|
814
921
|
await fs.promises
|
|
815
922
|
.rm(canonicalPath, { recursive: true, force: true })
|
|
816
923
|
.catch(() => {});
|
|
924
|
+
let restored = false;
|
|
817
925
|
if (retiredPath) {
|
|
818
926
|
try {
|
|
819
927
|
await fs.promises.rename(retiredPath, canonicalPath);
|
|
928
|
+
restored = true;
|
|
820
929
|
} catch (restoreErr) {
|
|
821
930
|
logger.error(
|
|
822
931
|
"Failed to restore retired package after install rollback",
|
|
@@ -832,6 +941,12 @@ export class Environment {
|
|
|
832
941
|
.rm(stagingPath, { recursive: true, force: true })
|
|
833
942
|
.catch(() => {});
|
|
834
943
|
this.deletePackageStatus(packageName);
|
|
944
|
+
logger.debug("install.phase2.rollback", {
|
|
945
|
+
environmentName: this.environmentName,
|
|
946
|
+
packageName,
|
|
947
|
+
restored,
|
|
948
|
+
errorName: err instanceof Error ? err.name : "Unknown",
|
|
949
|
+
});
|
|
835
950
|
throw err;
|
|
836
951
|
}
|
|
837
952
|
|
|
@@ -847,6 +962,11 @@ export class Environment {
|
|
|
847
962
|
if (retiredPath) {
|
|
848
963
|
const pathToClean = retiredPath;
|
|
849
964
|
setImmediate(() => {
|
|
965
|
+
logger.debug("install.phase3.retired_cleanup", {
|
|
966
|
+
environmentName: this.environmentName,
|
|
967
|
+
packageName,
|
|
968
|
+
retiredPath: pathToClean,
|
|
969
|
+
});
|
|
850
970
|
void fs.promises
|
|
851
971
|
.rm(pathToClean, { recursive: true, force: true })
|
|
852
972
|
.catch((err) => {
|
|
@@ -4,8 +4,12 @@ import * as os from "os";
|
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
|
|
6
6
|
import { ServiceUnavailableError } from "../errors";
|
|
7
|
+
import {
|
|
8
|
+
startMetricsHarness,
|
|
9
|
+
type MetricsHarness,
|
|
10
|
+
} from "../test_helpers/metrics_harness";
|
|
7
11
|
import { buildEnvironmentMalloyConfig } from "./connection";
|
|
8
|
-
import { Environment } from "./environment";
|
|
12
|
+
import { Environment, resetAdmissionTelemetryForTesting } from "./environment";
|
|
9
13
|
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -178,3 +182,163 @@ describe("Environment admission gate (memory governor choke point)", () => {
|
|
|
178
182
|
expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
|
|
179
183
|
});
|
|
180
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("Environment.assertCanAdmitQuery (query-path back-pressure)", () => {
|
|
187
|
+
let envDir: string;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
envDir = fs.mkdtempSync(
|
|
191
|
+
path.join(os.tmpdir(), "publisher-env-query-admission-"),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
fs.rmSync(envDir, { recursive: true, force: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("is a no-op when no governor is attached", () => {
|
|
200
|
+
const env = makeEnvironment(envDir);
|
|
201
|
+
// No governor — must not throw. Equivalent of an OSS / non-Docker
|
|
202
|
+
// deployment that never opted into PUBLISHER_MAX_MEMORY_BYTES.
|
|
203
|
+
expect(() => env.assertCanAdmitQuery()).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("is a no-op when the governor is happy (under high-water mark)", () => {
|
|
207
|
+
const env = makeEnvironment(envDir);
|
|
208
|
+
const governor = new StubGovernor();
|
|
209
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
210
|
+
governor.backpressured = false;
|
|
211
|
+
|
|
212
|
+
expect(() => env.assertCanAdmitQuery()).not.toThrow();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("throws ServiceUnavailableError (→503) when back-pressured", () => {
|
|
216
|
+
const env = makeEnvironment(envDir);
|
|
217
|
+
const governor = new StubGovernor();
|
|
218
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
219
|
+
governor.backpressured = true;
|
|
220
|
+
|
|
221
|
+
expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("error message names the environment so operators can pinpoint the hot pod's load", () => {
|
|
225
|
+
const env = makeEnvironment(envDir);
|
|
226
|
+
const governor = new StubGovernor();
|
|
227
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
228
|
+
governor.backpressured = true;
|
|
229
|
+
|
|
230
|
+
let caught: unknown;
|
|
231
|
+
try {
|
|
232
|
+
env.assertCanAdmitQuery();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
caught = err;
|
|
235
|
+
}
|
|
236
|
+
expect(caught).toBeInstanceOf(ServiceUnavailableError);
|
|
237
|
+
expect((caught as Error).message).toContain('environment "test-env"');
|
|
238
|
+
expect((caught as Error).message).toContain("memory pressure");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("clearing back-pressure immediately re-admits queries (matches governor hysteresis)", () => {
|
|
242
|
+
const env = makeEnvironment(envDir);
|
|
243
|
+
const governor = new StubGovernor();
|
|
244
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
245
|
+
|
|
246
|
+
governor.backpressured = true;
|
|
247
|
+
expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
|
|
248
|
+
|
|
249
|
+
governor.backpressured = false;
|
|
250
|
+
expect(() => env.assertCanAdmitQuery()).not.toThrow();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("detaching the governor reverts to legacy admit-everything", () => {
|
|
254
|
+
const env = makeEnvironment(envDir);
|
|
255
|
+
const governor = new StubGovernor();
|
|
256
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
257
|
+
governor.backpressured = true;
|
|
258
|
+
|
|
259
|
+
env.setMemoryGovernor(null);
|
|
260
|
+
// Even with the stub still claiming back-pressure, a detached
|
|
261
|
+
// governor leaves nothing to consult. Mirrors the package-admission
|
|
262
|
+
// detach behavior.
|
|
263
|
+
expect(() => env.assertCanAdmitQuery()).not.toThrow();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("Environment admission telemetry", () => {
|
|
268
|
+
let envDir: string;
|
|
269
|
+
let harness: MetricsHarness;
|
|
270
|
+
|
|
271
|
+
beforeEach(async () => {
|
|
272
|
+
envDir = fs.mkdtempSync(
|
|
273
|
+
path.join(os.tmpdir(), "publisher-env-admission-telemetry-"),
|
|
274
|
+
);
|
|
275
|
+
harness = await startMetricsHarness();
|
|
276
|
+
resetAdmissionTelemetryForTesting();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
afterEach(async () => {
|
|
280
|
+
fs.rmSync(envDir, { recursive: true, force: true });
|
|
281
|
+
resetAdmissionTelemetryForTesting();
|
|
282
|
+
await harness.shutdown();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("publisher_query_admission_rejections_total ticks per query rejection, labeled by environment", async () => {
|
|
286
|
+
const env = makeEnvironment(envDir);
|
|
287
|
+
const governor = new StubGovernor();
|
|
288
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
289
|
+
governor.backpressured = true;
|
|
290
|
+
|
|
291
|
+
// Drive three rejections so the counter is unambiguous (a
|
|
292
|
+
// single-tick assertion can pass against a leaked counter
|
|
293
|
+
// from a different test; three is harder to fake).
|
|
294
|
+
for (let i = 0; i < 3; i++) {
|
|
295
|
+
expect(() => env.assertCanAdmitQuery()).toThrow(
|
|
296
|
+
ServiceUnavailableError,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
expect(
|
|
300
|
+
await harness.collectCounter(
|
|
301
|
+
"publisher_query_admission_rejections_total",
|
|
302
|
+
{ environment: "test-env" },
|
|
303
|
+
),
|
|
304
|
+
).toBe(3);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("counter stays at zero when the governor is happy (no spurious ticks)", async () => {
|
|
308
|
+
const env = makeEnvironment(envDir);
|
|
309
|
+
const governor = new StubGovernor();
|
|
310
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
311
|
+
// Not back-pressured.
|
|
312
|
+
env.assertCanAdmitQuery();
|
|
313
|
+
env.assertCanAdmitQuery();
|
|
314
|
+
// No rejection should have been recorded; verifies that the
|
|
315
|
+
// counter doesn't tick on the happy-path admission either.
|
|
316
|
+
expect(
|
|
317
|
+
await harness.collectCounter(
|
|
318
|
+
"publisher_query_admission_rejections_total",
|
|
319
|
+
),
|
|
320
|
+
).toBe(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("publisher_package_admission_rejections_total ticks per package-load rejection", async () => {
|
|
324
|
+
// Ensure the package directory exists so the 404 doesn't
|
|
325
|
+
// short-circuit ahead of the back-pressure gate.
|
|
326
|
+
const pkgName = "real-pkg";
|
|
327
|
+
fs.mkdirSync(path.join(envDir, pkgName));
|
|
328
|
+
|
|
329
|
+
const env = makeEnvironment(envDir);
|
|
330
|
+
const governor = new StubGovernor();
|
|
331
|
+
env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
|
|
332
|
+
governor.backpressured = true;
|
|
333
|
+
|
|
334
|
+
await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
|
|
335
|
+
ServiceUnavailableError,
|
|
336
|
+
);
|
|
337
|
+
expect(
|
|
338
|
+
await harness.collectCounter(
|
|
339
|
+
"publisher_package_admission_rejections_total",
|
|
340
|
+
{ environment: "test-env", reason: "add a new package" },
|
|
341
|
+
),
|
|
342
|
+
).toBe(1);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -5,6 +5,7 @@ import * as sinon from "sinon";
|
|
|
5
5
|
import { components } from "../api";
|
|
6
6
|
import { isPublisherConfigFrozen } from "../config";
|
|
7
7
|
import { TEMP_DIR_PATH } from "../constants";
|
|
8
|
+
import { BadRequestError } from "../errors";
|
|
8
9
|
import { Environment } from "./environment";
|
|
9
10
|
import { EnvironmentStore } from "./environment_store";
|
|
10
11
|
|
|
@@ -1020,3 +1021,105 @@ describe("Project Service Error Recovery", () => {
|
|
|
1020
1021
|
);
|
|
1021
1022
|
});
|
|
1022
1023
|
});
|
|
1024
|
+
|
|
1025
|
+
const TRAVERSAL_NAMES: ReadonlyArray<readonly [string, string]> = [
|
|
1026
|
+
["leading traversal", "../etc"],
|
|
1027
|
+
["embedded traversal", "foo/../../bar"],
|
|
1028
|
+
["slash in name", "foo/bar"],
|
|
1029
|
+
["backslash in name", "foo\\bar"],
|
|
1030
|
+
["leading dot", ".staging"],
|
|
1031
|
+
["bare dot-dot", ".."],
|
|
1032
|
+
["bare dot", "."],
|
|
1033
|
+
["empty", ""],
|
|
1034
|
+
["NUL byte", "foo\0bar"],
|
|
1035
|
+
["oversized", "a".repeat(256)],
|
|
1036
|
+
["absolute", "/etc/passwd"],
|
|
1037
|
+
] as const;
|
|
1038
|
+
|
|
1039
|
+
describe("EnvironmentStore path-injection guards", () => {
|
|
1040
|
+
let environmentStore: EnvironmentStore;
|
|
1041
|
+
|
|
1042
|
+
beforeEach(async () => {
|
|
1043
|
+
if (existsSync(serverRootPath)) {
|
|
1044
|
+
rmSync(serverRootPath, { recursive: true, force: true });
|
|
1045
|
+
}
|
|
1046
|
+
mkdirSync(serverRootPath);
|
|
1047
|
+
mock(isPublisherConfigFrozen).mockReturnValue(false);
|
|
1048
|
+
mock.module("../config", () => ({
|
|
1049
|
+
isPublisherConfigFrozen: () => false,
|
|
1050
|
+
}));
|
|
1051
|
+
environmentStore = new EnvironmentStore(serverRootPath);
|
|
1052
|
+
await environmentStore.finishedInitialization;
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
afterEach(() => {
|
|
1056
|
+
if (existsSync(serverRootPath)) {
|
|
1057
|
+
rmSync(serverRootPath, { recursive: true, force: true });
|
|
1058
|
+
}
|
|
1059
|
+
mkdirSync(serverRootPath);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
describe("addEnvironment", () => {
|
|
1063
|
+
it.each(TRAVERSAL_NAMES)(
|
|
1064
|
+
"rejects %s as environment.name (%p)",
|
|
1065
|
+
async (_label, name) => {
|
|
1066
|
+
await expect(
|
|
1067
|
+
environmentStore.addEnvironment({ name } as never, true),
|
|
1068
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
it.each(TRAVERSAL_NAMES)(
|
|
1073
|
+
"rejects %s as packages[].name (%p)",
|
|
1074
|
+
async (_label, packageName) => {
|
|
1075
|
+
await expect(
|
|
1076
|
+
environmentStore.addEnvironment(
|
|
1077
|
+
{
|
|
1078
|
+
name: "ok-env",
|
|
1079
|
+
packages: [
|
|
1080
|
+
{
|
|
1081
|
+
name: packageName,
|
|
1082
|
+
location: "https://github.com/example/repo",
|
|
1083
|
+
},
|
|
1084
|
+
],
|
|
1085
|
+
} as never,
|
|
1086
|
+
true,
|
|
1087
|
+
),
|
|
1088
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
1089
|
+
},
|
|
1090
|
+
);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("updateEnvironment", () => {
|
|
1094
|
+
it.each(TRAVERSAL_NAMES)(
|
|
1095
|
+
"rejects %s as environment.name (%p)",
|
|
1096
|
+
async (_label, name) => {
|
|
1097
|
+
await expect(
|
|
1098
|
+
environmentStore.updateEnvironment({ name } as never),
|
|
1099
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
1100
|
+
},
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
describe("deleteEnvironment", () => {
|
|
1105
|
+
it.each(TRAVERSAL_NAMES)(
|
|
1106
|
+
"rejects %s as environmentName (%p)",
|
|
1107
|
+
async (_label, name) => {
|
|
1108
|
+
await expect(
|
|
1109
|
+
environmentStore.deleteEnvironment(name),
|
|
1110
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
1111
|
+
},
|
|
1112
|
+
);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
describe("getEnvironment", () => {
|
|
1116
|
+
it.each(TRAVERSAL_NAMES)(
|
|
1117
|
+
"rejects %s as environmentName (%p)",
|
|
1118
|
+
async (_label, name) => {
|
|
1119
|
+
await expect(
|
|
1120
|
+
environmentStore.getEnvironment(name),
|
|
1121
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
1122
|
+
},
|
|
1123
|
+
);
|
|
1124
|
+
});
|
|
1125
|
+
});
|