@malloy-publisher/server 0.0.199 → 0.0.200
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 +76 -111
- package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-CgKNjySu.js} +1 -1
- package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
- package/dist/app/assets/{MainPage-DsVt5QGM.js → MainPage-CAwb8U82.js} +2 -2
- package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-C0Uevsw9.js} +1 -1
- package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-Cu-u9k1g.js} +1 -1
- package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-DVwPh2Ql.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-DW38R2Zv.js} +1 -1
- package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
- package/dist/app/assets/{index-D1pdwrUW.js → index-BGdcKsFF.js} +1 -1
- package/dist/app/assets/{index-BUp81Qdm.js → index-CTx4v4_3.js} +1 -1
- package/dist/app/assets/index-DE6d5jEy.js +452 -0
- package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-C1Mi1uRm.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +1482 -1010
- package/package.json +1 -1
- 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/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 +25 -47
- package/src/service/connection.ts +8 -2
- package/src/service/environment.ts +82 -2
- 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/model.spec.ts +193 -3
- package/src/service/model.ts +80 -12
- 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/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`,
|
|
@@ -218,7 +263,7 @@ export class Environment {
|
|
|
218
263
|
try {
|
|
219
264
|
readme = (
|
|
220
265
|
await fs.promises.readFile(
|
|
221
|
-
|
|
266
|
+
safeJoinUnderRoot(this.environmentPath, README_NAME),
|
|
222
267
|
)
|
|
223
268
|
).toString();
|
|
224
269
|
} catch {
|
|
@@ -579,8 +624,43 @@ export class Environment {
|
|
|
579
624
|
): void {
|
|
580
625
|
if (allowAdmission) return;
|
|
581
626
|
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
627
|
+
// Increment *before* throwing so the metric ticks even on
|
|
628
|
+
// the not-uncommon "caught and swallowed" path. The label
|
|
629
|
+
// shape mirrors `assertCanAdmitQuery` so a dashboard panel
|
|
630
|
+
// can sum both rejection kinds by environment.
|
|
631
|
+
getPackageAdmissionRejectionsCounter().add(1, {
|
|
632
|
+
environment: this.environmentName,
|
|
633
|
+
reason,
|
|
634
|
+
});
|
|
635
|
+
throw new ServiceUnavailableError(
|
|
636
|
+
`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.`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Reject incoming queries with HTTP 503 when the memory governor
|
|
642
|
+
* has tripped its high-water mark. Used by every query controller
|
|
643
|
+
* (connection SQL, model query, notebook cell, MCP `execute_query`)
|
|
644
|
+
* to shed load before the query runs — complementing
|
|
645
|
+
* {@link assertCanAdmitNewPackage}, which only fires on cache-miss
|
|
646
|
+
* package loads and so leaves already-loaded packages fully
|
|
647
|
+
* queryable under pressure. With this in place, "back-pressured"
|
|
648
|
+
* means "no new work of any kind" until the governor's low-water
|
|
649
|
+
* mark is crossed.
|
|
650
|
+
*
|
|
651
|
+
* Cheap O(1) boolean read; no allocation when happy.
|
|
652
|
+
*/
|
|
653
|
+
public assertCanAdmitQuery(): void {
|
|
654
|
+
if (!this.memoryGovernor?.isBackpressured()) return;
|
|
655
|
+
// Tick first so the counter reflects every rejection even
|
|
656
|
+
// when the controller's catch block swallows the error (e.g.
|
|
657
|
+
// an MCP tool surfaces it as a content payload rather than
|
|
658
|
+
// letting it bubble to the HTTP error mapper).
|
|
659
|
+
getQueryAdmissionRejectionsCounter().add(1, {
|
|
660
|
+
environment: this.environmentName,
|
|
661
|
+
});
|
|
582
662
|
throw new ServiceUnavailableError(
|
|
583
|
-
`Publisher is under memory pressure and cannot
|
|
663
|
+
`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
664
|
);
|
|
585
665
|
}
|
|
586
666
|
|
|
@@ -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
|
+
});
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
} from "../errors";
|
|
28
28
|
import { getOperationalState, markNotReady, markReady } from "../health";
|
|
29
29
|
import { formatDuration, logger } from "../logger";
|
|
30
|
+
import {
|
|
31
|
+
assertSafeEnvironmentPath,
|
|
32
|
+
assertSafePackageName,
|
|
33
|
+
safeJoinUnderRoot,
|
|
34
|
+
} from "../path_safety";
|
|
30
35
|
import { Connection } from "../storage/DatabaseInterface";
|
|
31
36
|
import { StorageConfig, StorageManager } from "../storage/StorageManager";
|
|
32
37
|
import { Environment, PackageStatus } from "./environment";
|
|
@@ -756,6 +761,7 @@ export class EnvironmentStore {
|
|
|
756
761
|
reload: boolean = false,
|
|
757
762
|
): Promise<Environment> {
|
|
758
763
|
await this.finishedInitialization;
|
|
764
|
+
assertSafePackageName(environmentName);
|
|
759
765
|
|
|
760
766
|
// Check if environment is already loaded first
|
|
761
767
|
const environment = this.environments.get(environmentName);
|
|
@@ -816,9 +822,10 @@ export class EnvironmentStore {
|
|
|
816
822
|
if (!skipInitialization && this.publisherConfigIsFrozen) {
|
|
817
823
|
throw new FrozenConfigError();
|
|
818
824
|
}
|
|
825
|
+
assertSafePackageName(environment.name);
|
|
819
826
|
const environmentName = environment.name;
|
|
820
|
-
|
|
821
|
-
|
|
827
|
+
for (const _package of environment.packages || []) {
|
|
828
|
+
assertSafePackageName(_package.name);
|
|
822
829
|
}
|
|
823
830
|
// Check if environment already exists and update it instead of creating a new one
|
|
824
831
|
const existingEnvironment = this.environments.get(environmentName);
|
|
@@ -884,6 +891,7 @@ export class EnvironmentStore {
|
|
|
884
891
|
}
|
|
885
892
|
|
|
886
893
|
public async unzipEnvironment(absoluteEnvironmentPath: string) {
|
|
894
|
+
assertSafeEnvironmentPath(absoluteEnvironmentPath);
|
|
887
895
|
const startedAt = Date.now();
|
|
888
896
|
logger.info(
|
|
889
897
|
`Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
|
|
@@ -930,9 +938,10 @@ export class EnvironmentStore {
|
|
|
930
938
|
throw new FrozenConfigError();
|
|
931
939
|
}
|
|
932
940
|
validateEnvironmentAzureUrls(environment);
|
|
941
|
+
assertSafePackageName(environment.name);
|
|
933
942
|
const environmentName = environment.name;
|
|
934
|
-
|
|
935
|
-
|
|
943
|
+
for (const _package of environment.packages || []) {
|
|
944
|
+
assertSafePackageName(_package.name);
|
|
936
945
|
}
|
|
937
946
|
const existingEnvironment = this.environments.get(environmentName);
|
|
938
947
|
if (!existingEnvironment) {
|
|
@@ -953,6 +962,7 @@ export class EnvironmentStore {
|
|
|
953
962
|
if (this.publisherConfigIsFrozen) {
|
|
954
963
|
throw new FrozenConfigError();
|
|
955
964
|
}
|
|
965
|
+
assertSafePackageName(environmentName);
|
|
956
966
|
const environment = this.environments.get(environmentName);
|
|
957
967
|
if (!environment) {
|
|
958
968
|
return;
|
|
@@ -1027,15 +1037,17 @@ export class EnvironmentStore {
|
|
|
1027
1037
|
}
|
|
1028
1038
|
|
|
1029
1039
|
private async scaffoldEnvironment(environment: ApiEnvironment) {
|
|
1040
|
+
assertSafePackageName(environment.name);
|
|
1030
1041
|
const environmentName = environment.name;
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1042
|
+
const absoluteEnvironmentPath = safeJoinUnderRoot(
|
|
1043
|
+
this.serverRootPath,
|
|
1044
|
+
PUBLISHER_DATA_DIR,
|
|
1045
|
+
environmentName,
|
|
1046
|
+
);
|
|
1035
1047
|
await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
|
|
1036
1048
|
if (environment.readme) {
|
|
1037
1049
|
await fs.promises.writeFile(
|
|
1038
|
-
|
|
1050
|
+
safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
|
|
1039
1051
|
environment.readme,
|
|
1040
1052
|
);
|
|
1041
1053
|
}
|
|
@@ -1071,7 +1083,12 @@ export class EnvironmentStore {
|
|
|
1071
1083
|
environmentName: string,
|
|
1072
1084
|
packages: ApiEnvironment["packages"],
|
|
1073
1085
|
) {
|
|
1074
|
-
|
|
1086
|
+
assertSafePackageName(environmentName);
|
|
1087
|
+
const absoluteTargetPath = safeJoinUnderRoot(
|
|
1088
|
+
this.serverRootPath,
|
|
1089
|
+
PUBLISHER_DATA_DIR,
|
|
1090
|
+
environmentName,
|
|
1091
|
+
);
|
|
1075
1092
|
|
|
1076
1093
|
await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
|
|
1077
1094
|
|
|
@@ -1126,7 +1143,10 @@ export class EnvironmentStore {
|
|
|
1126
1143
|
.update(groupedLocation)
|
|
1127
1144
|
.digest("hex")
|
|
1128
1145
|
.substring(0, 16); // Use first 16 chars for shorter paths
|
|
1129
|
-
const tempDownloadPath =
|
|
1146
|
+
const tempDownloadPath = safeJoinUnderRoot(
|
|
1147
|
+
absoluteTargetPath,
|
|
1148
|
+
`.temp_${locationHash}`,
|
|
1149
|
+
);
|
|
1130
1150
|
await fs.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
1131
1151
|
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
|
1132
1152
|
try {
|
|
@@ -1140,7 +1160,11 @@ export class EnvironmentStore {
|
|
|
1140
1160
|
// Extract each package from the downloaded content
|
|
1141
1161
|
for (const _package of packagesForLocation) {
|
|
1142
1162
|
const packageDir = _package.name;
|
|
1143
|
-
|
|
1163
|
+
assertSafePackageName(packageDir);
|
|
1164
|
+
const absolutePackagePath = safeJoinUnderRoot(
|
|
1165
|
+
absoluteTargetPath,
|
|
1166
|
+
packageDir,
|
|
1167
|
+
);
|
|
1144
1168
|
// For GitHub URLs, extract the subdirectory path from the original location
|
|
1145
1169
|
let sourcePath: string;
|
|
1146
1170
|
if (this.isGitHubURL(_package.location)) {
|
|
@@ -1151,7 +1175,7 @@ export class EnvironmentStore {
|
|
|
1151
1175
|
const subPathMatch =
|
|
1152
1176
|
_package.location.match(/\/tree\/[^/]+\/(.+)$/);
|
|
1153
1177
|
if (subPathMatch) {
|
|
1154
|
-
sourcePath =
|
|
1178
|
+
sourcePath = safeJoinUnderRoot(
|
|
1155
1179
|
tempDownloadPath,
|
|
1156
1180
|
subPathMatch[1],
|
|
1157
1181
|
);
|
|
@@ -1168,7 +1192,10 @@ export class EnvironmentStore {
|
|
|
1168
1192
|
if (this.isLocalPath(_package.location)) {
|
|
1169
1193
|
sourcePath = _package.location;
|
|
1170
1194
|
} else {
|
|
1171
|
-
sourcePath =
|
|
1195
|
+
sourcePath = safeJoinUnderRoot(
|
|
1196
|
+
tempDownloadPath,
|
|
1197
|
+
groupedLocation,
|
|
1198
|
+
);
|
|
1172
1199
|
}
|
|
1173
1200
|
}
|
|
1174
1201
|
|
|
@@ -1347,6 +1374,10 @@ export class EnvironmentStore {
|
|
|
1347
1374
|
environmentName: string,
|
|
1348
1375
|
packageName: string,
|
|
1349
1376
|
) {
|
|
1377
|
+
// `environmentPath` is the operator-supplied mount source and may
|
|
1378
|
+
// legitimately be a relative path that resolves outside the
|
|
1379
|
+
// server root; only the target is asserted.
|
|
1380
|
+
assertSafeEnvironmentPath(absoluteTargetPath);
|
|
1350
1381
|
if (environmentPath.endsWith(".zip")) {
|
|
1351
1382
|
environmentPath = await this.unzipEnvironment(environmentPath);
|
|
1352
1383
|
}
|
|
@@ -1374,6 +1405,7 @@ export class EnvironmentStore {
|
|
|
1374
1405
|
absoluteDirPath: string,
|
|
1375
1406
|
isCompressedFile: boolean,
|
|
1376
1407
|
) {
|
|
1408
|
+
assertSafeEnvironmentPath(absoluteDirPath);
|
|
1377
1409
|
const trimmedPath = gcsPath.slice(5);
|
|
1378
1410
|
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
1379
1411
|
const prefix = prefixParts.join("/");
|
|
@@ -1396,10 +1428,15 @@ export class EnvironmentStore {
|
|
|
1396
1428
|
}
|
|
1397
1429
|
await Promise.all(
|
|
1398
1430
|
files.map(async (file) => {
|
|
1399
|
-
|
|
1431
|
+
// Strip leading `/` left over from prefix removal when the
|
|
1432
|
+
// GCS prefix lacked a trailing slash — otherwise
|
|
1433
|
+
// `safeJoinUnderRoot` treats it as absolute and rejects.
|
|
1434
|
+
const relativeFilePath = file.name
|
|
1435
|
+
.replace(prefix, "")
|
|
1436
|
+
.replace(/^\/+/, "");
|
|
1400
1437
|
const absoluteFilePath = isCompressedFile
|
|
1401
1438
|
? absoluteDirPath
|
|
1402
|
-
:
|
|
1439
|
+
: safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
|
|
1403
1440
|
if (file.name.endsWith("/")) {
|
|
1404
1441
|
return;
|
|
1405
1442
|
}
|
|
@@ -1424,6 +1461,7 @@ export class EnvironmentStore {
|
|
|
1424
1461
|
absoluteDirPath: string,
|
|
1425
1462
|
isCompressedFile: boolean = false,
|
|
1426
1463
|
) {
|
|
1464
|
+
assertSafeEnvironmentPath(absoluteDirPath);
|
|
1427
1465
|
const trimmedPath = s3Path.slice(5);
|
|
1428
1466
|
const [bucketName, ...prefixParts] = trimmedPath.split("/");
|
|
1429
1467
|
const prefix = prefixParts.join("/");
|
|
@@ -1477,11 +1515,16 @@ export class EnvironmentStore {
|
|
|
1477
1515
|
if (!key) {
|
|
1478
1516
|
return;
|
|
1479
1517
|
}
|
|
1480
|
-
|
|
1518
|
+
// Strip leading `/` left over from prefix removal when the
|
|
1519
|
+
// S3 prefix lacked a trailing slash — otherwise
|
|
1520
|
+
// `safeJoinUnderRoot` treats it as absolute and rejects.
|
|
1521
|
+
const relativeFilePath = key
|
|
1522
|
+
.replace(prefix, "")
|
|
1523
|
+
.replace(/^\/+/, "");
|
|
1481
1524
|
if (!relativeFilePath || relativeFilePath.endsWith("/")) {
|
|
1482
1525
|
return;
|
|
1483
1526
|
}
|
|
1484
|
-
const absoluteFilePath =
|
|
1527
|
+
const absoluteFilePath = safeJoinUnderRoot(
|
|
1485
1528
|
absoluteDirPath,
|
|
1486
1529
|
relativeFilePath,
|
|
1487
1530
|
);
|
|
@@ -1532,6 +1575,7 @@ export class EnvironmentStore {
|
|
|
1532
1575
|
}
|
|
1533
1576
|
|
|
1534
1577
|
async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
|
|
1578
|
+
assertSafeEnvironmentPath(absoluteDirPath);
|
|
1535
1579
|
// First we'll clone the repo without the additional path
|
|
1536
1580
|
// E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
|
|
1537
1581
|
const githubInfo = this.parseGitHubUrl(githubUrl);
|
|
@@ -1539,7 +1583,11 @@ export class EnvironmentStore {
|
|
|
1539
1583
|
throw new Error(`Invalid GitHub URL: ${githubUrl}`);
|
|
1540
1584
|
}
|
|
1541
1585
|
const { owner, repoName, packagePath } = githubInfo;
|
|
1542
|
-
|
|
1586
|
+
// `packagePath` is captured with a leading `/`; strip it so the
|
|
1587
|
+
// value is a relative segment usable with `safeJoinUnderRoot`.
|
|
1588
|
+
const cleanPackagePath = (
|
|
1589
|
+
packagePath?.replace("/tree/main", "") || ""
|
|
1590
|
+
).replace(/^\/+/, "");
|
|
1543
1591
|
|
|
1544
1592
|
// We'll make sure whatever was in absoluteDirPath is removed,
|
|
1545
1593
|
// so we have a nice a clean directory where we can clone the repo
|
|
@@ -1579,7 +1627,10 @@ export class EnvironmentStore {
|
|
|
1579
1627
|
|
|
1580
1628
|
// Remove all contents of absoluteDirPath (/var/publisher/asd123)
|
|
1581
1629
|
// except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
|
|
1582
|
-
const packageFullPath =
|
|
1630
|
+
const packageFullPath = safeJoinUnderRoot(
|
|
1631
|
+
absoluteDirPath,
|
|
1632
|
+
cleanPackagePath,
|
|
1633
|
+
);
|
|
1583
1634
|
|
|
1584
1635
|
// Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
|
|
1585
1636
|
const packageExists = await fs.promises
|
|
@@ -1598,7 +1649,7 @@ export class EnvironmentStore {
|
|
|
1598
1649
|
for (const entry of dirContents) {
|
|
1599
1650
|
// Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
|
|
1600
1651
|
if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
|
|
1601
|
-
await fs.promises.rm(
|
|
1652
|
+
await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
|
|
1602
1653
|
recursive: true,
|
|
1603
1654
|
force: true,
|
|
1604
1655
|
});
|
|
@@ -1609,8 +1660,8 @@ export class EnvironmentStore {
|
|
|
1609
1660
|
const packageContents = await fs.promises.readdir(packageFullPath);
|
|
1610
1661
|
for (const entry of packageContents) {
|
|
1611
1662
|
await fs.promises.rename(
|
|
1612
|
-
|
|
1613
|
-
|
|
1663
|
+
safeJoinUnderRoot(packageFullPath, entry),
|
|
1664
|
+
safeJoinUnderRoot(absoluteDirPath, entry),
|
|
1614
1665
|
);
|
|
1615
1666
|
}
|
|
1616
1667
|
|